Initial commit - Project start (July 17, 2025)
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m28s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Has been skipped

Original: feat: enhance security logging and ensure rate limit blocks are properly tracked

- Add comprehensive logging for rate limit blocks with full request details
  - IP address (with proper proxy detection), user agent, headers, timestamps
  - Rate limit info (current count, limit, remaining, reset time)
  - Separate tracking for auth vs general endpoints

- Enhance authentication failure logging
  - JWT validation failures with detailed error info
  - Admin auth attempts without token
  - Failed token validation with user context
  - All events include IP, path, method, user agent

- Improve Winston logger configuration for production
  - Add automatic log rotation (10MB errors, 50MB combined)
  - Create separate security.log for auth/rate limit events
  - Ensure logs directory exists automatically
  - Add structured JSON format for log aggregation
  - Support container logging with LOG_TO_CONSOLE env var

- Create comprehensive documentation
  - Security logging guide with examples
  - Monitoring recommendations
  - Configuration reference

- Add test script to verify logging functionality

All rate limit settings remain configurable via admin panel:
- Window duration, max requests, auth limits
- Skip authenticated requests option
- Public endpoints only option

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-18 19:25:15 +02:00
commit 1773ed5f95
354 changed files with 61508 additions and 0 deletions
+383
View File
@@ -0,0 +1,383 @@
import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Calendar, AlertCircle, Clock } from 'lucide-react';
import { differenceInDays, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../hooks/useLocalizedDate';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, Input, Button, Loading, ReCaptcha } from '../components/common';
import { useGalleryAuth, useTheme } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery';
import { analyticsService } from '../services/analytics.service';
import { api } from '../config/api';
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
import { buildResourceUrl } from '../utils/url';
export const GalleryPage: React.FC = () => {
const { slug, token } = useParams<{ slug: string; token?: string }>();
const { isAuthenticated, login, event } = useGalleryAuth();
const { t, i18n } = useTranslation();
const { format } = useLocalizedDate();
const { setTheme } = useTheme();
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);
// Fetch branding settings
const { data: settingsData } = useQuery({
queryKey: ['gallery-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Set language from admin settings when on login page
React.useEffect(() => {
if (!isAuthenticated && settingsData?.default_language) {
i18n.changeLanguage(settingsData.default_language);
}
}, [settingsData, isAuthenticated, i18n]);
// Apply theme for login page
React.useEffect(() => {
if (!isAuthenticated && galleryInfo && settingsData) {
let themeToApply = null;
if (galleryInfo.color_theme) {
try {
// Check if it's a valid JSON string
if (galleryInfo.color_theme.startsWith('{')) {
themeToApply = JSON.parse(galleryInfo.color_theme);
} else {
// Handle legacy theme names - check if it's a preset
const preset = GALLERY_THEME_PRESETS[galleryInfo.color_theme];
if (preset) {
themeToApply = preset.config;
} else {
// Unknown theme name, fall back to global theme
if (settingsData.theme_config) {
themeToApply = settingsData.theme_config;
}
}
}
} catch (e) {
console.error('Failed to parse event theme:', e);
// Fall back to global theme
if (settingsData.theme_config) {
themeToApply = settingsData.theme_config;
}
}
} else if (settingsData.theme_config) {
// No event theme, use global theme
themeToApply = settingsData.theme_config;
}
// Apply theme
if (themeToApply) {
setTheme(themeToApply);
}
}
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
// Calculate days until expiration
const daysUntilExpiration = galleryInfo
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
: null;
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
e.stopPropagation(); // Prevent any bubbling
if (!password.trim()) {
setLoginError(t('auth.pleaseEnterPassword'));
return;
}
try {
setIsLoggingIn(true);
setLoginError(null);
await login(slug!, password, recaptchaToken);
// Track successful password entry
analyticsService.trackGalleryEvent('password_entry', {
gallery: slug,
success: true
});
} catch (error: any) {
console.error('Login error:', error);
const errorMessage = error.response?.data?.error || 'Invalid password';
const statusCode = error.response?.status;
// Map backend error messages to user-friendly translations
if (statusCode === 401 || errorMessage.toLowerCase().includes('invalid password')) {
setLoginError(t('auth.wrongPassword'));
} else if (statusCode === 429 || errorMessage.toLowerCase().includes('too many')) {
setLoginError(t('auth.tooManyAttempts'));
} else if (statusCode === 404) {
setLoginError(t('errors.galleryNotFound'));
} else {
setLoginError(t('auth.invalidPassword'));
}
// Track failed password entry
analyticsService.trackGalleryEvent('password_entry', {
gallery: slug,
success: false,
statusCode
});
// Keep the password field to allow retry
// Do not clear the password
} finally {
setIsLoggingIn(false);
}
};
// Show loading state
if (isLoadingInfo) {
return (
<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>
</div>
);
}
// 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" 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={buildResourceUrl(settingsData.branding_logo_url)}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<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(isArchived ? 'errors.galleryArchived' : 'errors.galleryNotFound')}
</h2>
<p className="text-neutral-600">
{t(isArchived ? 'errors.galleryArchivedMessage' : 'errors.galleryNotFoundMessage')}
</p>
</CardContent>
</Card>
</div>
{/* Legal Links */}
<div className="p-8 text-center">
<div className="flex items-center justify-center gap-4">
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-neutral-400">|</span>
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
</div>
</div>
);
}
// Show expired state
if (galleryInfo?.is_expired) {
return (
<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={buildResourceUrl(settingsData.branding_logo_url)}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<Clock className="w-16 h-16 text-amber-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">{t('gallery.expired')}</h2>
<p className="text-neutral-600 mb-4">
{t('gallery.expiredOn', { date: format(parseISO(galleryInfo.expires_at), 'PP') })}
</p>
<p className="text-sm text-neutral-500">
{t('gallery.contactOrganizer')}
</p>
</CardContent>
</Card>
</div>
{/* Legal Links */}
<div className="p-8 text-center">
<div className="flex items-center justify-center gap-4">
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-neutral-400">|</span>
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
</div>
</div>
);
}
// Show gallery view if authenticated
if (isAuthenticated && event) {
return <GalleryView slug={slug!} event={event} />;
}
// Show login form
return (
<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-lg">
{/* Logo/Header */}
<div className="text-center mb-4 sm:mb-6">
<img
src={settingsData?.branding_logo_url ?
buildResourceUrl(settingsData.branding_logo_url) :
'/picpeak-logo-transparent.png'
}
alt={settingsData?.branding_company_name || 'PicPeak'}
className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4"
/>
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-2 px-2" style={{ color: 'var(--color-text, #171717)' }}>
{galleryInfo?.event_name}
</h1>
<div className="flex items-center justify-center text-xs sm:text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
<span className="truncate">{format(parseISO(galleryInfo!.event_date), 'PP')}</span>
</div>
</div>
{/* Expiration Warning */}
{daysUntilExpiration !== null && daysUntilExpiration <= 7 && (
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-start">
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 mt-0.5 mr-2 flex-shrink-0" />
<div>
<p className="text-xs sm:text-sm font-medium text-amber-800">
{t('gallery.expiresIn', { count: daysUntilExpiration })}
</p>
<p className="text-xs text-amber-700 mt-1">
{t('gallery.downloadBefore')}
</p>
</div>
</div>
</div>
)}
{/* Login Card */}
<Card>
<CardContent className="p-4 sm:p-6">
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="password"
label={t('auth.password')}
placeholder={t('auth.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
error={loginError || undefined}
autoFocus
className="text-sm sm:text-base"
/>
<ReCaptcha
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
/>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full text-sm sm:text-base"
isLoading={isLoggingIn}
disabled={isLoggingIn}
>
{t('gallery.viewGallery')}
</Button>
</form>
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
{t('auth.passwordHint')}
</p>
</CardContent>
</Card>
{/* Legal Links */}
<div className="text-center mt-4 sm:mt-6">
<div className="flex items-center justify-center gap-4">
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-neutral-400">|</span>
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
</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 />;
};
+305
View File
@@ -0,0 +1,305 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import {
Calendar,
AlertTriangle,
Download,
Eye,
Clock,
Plus,
HardDrive,
Image,
Archive,
Heart
} from 'lucide-react';
import { differenceInDays, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Card, Loading } from '../../components/common';
import { useQuery } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { adminService } from '../../services/admin.service';
interface StatCard {
title: string;
value: string | number;
change?: string;
icon: React.ComponentType<{ className?: string }>;
color: string;
}
export const AdminDashboard: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { format, formatDistanceToNow } = useLocalizedDate();
// Fetch dashboard statistics
const { data: dashboardStats, isLoading: statsLoading } = useQuery({
queryKey: ['admin-dashboard-stats'],
queryFn: () => adminService.getDashboardStats(),
});
// Fetch recent activity
const { data: recentActivity } = useQuery({
queryKey: ['admin-recent-activity'],
queryFn: () => adminService.getRecentActivity(10),
});
// Fetch system health
const { data: systemHealth } = useQuery({
queryKey: ['admin-system-health'],
queryFn: () => adminService.getSystemHealth(),
refetchInterval: 30000, // Refresh every 30 seconds
});
// Fetch events data for expiring events
const { data: eventsData, isLoading: eventsLoading } = useQuery({
queryKey: ['admin-events-summary'],
queryFn: () => eventsService.getEvents(1, 100),
});
const isLoading = statsLoading || eventsLoading;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('admin.loadingDashboard')} />
</div>
);
}
// Calculate expiring events
const activeEvents = eventsData?.events.filter(e => e.is_active && !e.is_archived) || [];
const expiringEvents = activeEvents.filter(e => {
const days = differenceInDays(parseISO(e.expires_at), new Date());
return days <= 7 && days > 0;
});
// Format numbers for display
const formatNumber = (num: number): string => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
};
// Build statistics cards - always show 8 cards in 2x4 grid
const stats: StatCard[] = [
{
title: t('admin.activeEvents'),
value: dashboardStats?.activeEvents || 0,
icon: Calendar,
color: 'text-green-600',
},
{
title: t('admin.expiringSoon'),
value: dashboardStats?.expiringEvents || 0,
change: t('admin.next7Days'),
icon: AlertTriangle,
color: 'text-orange-600',
},
{
title: t('admin.totalPhotos'),
value: formatNumber(dashboardStats?.totalPhotos || 0),
icon: Image,
color: 'text-blue-600',
},
{
title: t('admin.storageUsed'),
value: adminService.formatBytes(dashboardStats?.storageUsed || 0),
icon: HardDrive,
color: 'text-purple-600',
},
{
title: t('admin.totalViews'),
value: formatNumber(dashboardStats?.totalViews || 0),
change: dashboardStats?.viewsTrend ? t('admin.percentFromLastWeek', { percent: `${dashboardStats.viewsTrend > 0 ? '+' : ''}${dashboardStats.viewsTrend}` }) : undefined,
icon: Eye,
color: 'text-indigo-600',
},
{
title: t('admin.downloads'),
value: formatNumber(dashboardStats?.totalDownloads || 0),
change: dashboardStats?.downloadsTrend ? t('admin.percentFromLastWeek', { percent: `${dashboardStats.downloadsTrend > 0 ? '+' : ''}${dashboardStats.downloadsTrend}` }) : undefined,
icon: Download,
color: 'text-pink-600',
},
{
title: t('admin.archivedEvents'),
value: dashboardStats?.archivedEvents || 0,
icon: Archive,
color: 'text-gray-600',
},
{
title: t('admin.systemHealth'),
value: systemHealth ? t(`admin.health.${systemHealth.overall}`) : t('admin.health.checking'),
icon: Heart,
color: systemHealth?.overall === 'healthy' ? 'text-green-600' : systemHealth?.overall === 'warning' ? 'text-yellow-600' : 'text-red-600',
},
];
return (
<div>
{/* Page Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-neutral-900">{t('navigation.dashboard')}</h1>
<p className="text-neutral-600 mt-1">{t('admin.dashboardSubtitle')}</p>
</div>
<Button
variant="primary"
leftIcon={<Plus className="w-5 h-5" />}
onClick={() => navigate('/admin/events/new')}
>
{t('events.createEvent')}
</Button>
</div>
{/* Statistics Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{stats.map((stat) => (
<Card key={stat.title} className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-neutral-600">{stat.title}</p>
<p className="text-2xl font-bold text-neutral-900 mt-1">{stat.value}</p>
{stat.change && (
<p className="text-sm text-neutral-500 mt-1">{stat.change}</p>
)}
</div>
<div className={`p-3 rounded-full bg-neutral-100 ${stat.color}`}>
<stat.icon className="w-6 h-6" />
</div>
</div>
</Card>
))}
</div>
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Expiring Events */}
<div className="lg:col-span-2">
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">{t('admin.eventsExpiringSoon')}</h2>
<AlertTriangle className="w-5 h-5 text-orange-600" />
</div>
{expiringEvents.length === 0 ? (
<p className="text-neutral-600 py-8 text-center">{t('admin.noEventsExpiring')}</p>
) : (
<div className="space-y-3">
{expiringEvents.slice(0, 5).map((event) => {
const daysLeft = differenceInDays(parseISO(event.expires_at), new Date());
return (
<div
key={event.id}
className="flex items-center justify-between p-4 bg-orange-50 rounded-lg border border-orange-200 cursor-pointer hover:bg-orange-100 transition-colors"
onClick={() => navigate(`/admin/events/${event.id}`)}
>
<div>
<h3 className="font-medium text-neutral-900">{event.event_name}</h3>
<p className="text-sm text-neutral-600">
{format(parseISO(event.event_date), 'PP')}
</p>
</div>
<div className="text-right">
<p className="text-sm font-medium text-orange-600">
{t('admin.daysLeft', { count: daysLeft })}
</p>
<p className="text-xs text-neutral-500">
{t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}
</p>
</div>
</div>
);
})}
</div>
)}
{expiringEvents.length > 5 && (
<button
onClick={() => navigate('/admin/events?filter=expiring')}
className="w-full mt-4 text-sm text-primary-600 hover:text-primary-700 font-medium"
>
{t('admin.viewAllExpiringEvents', { count: expiringEvents.length })}
</button>
)}
</Card>
</div>
{/* Recent Activity */}
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">{t('admin.recentActivity')}</h2>
<Clock className="w-5 h-5 text-neutral-500" />
</div>
<div className="space-y-4">
{!recentActivity || recentActivity.length === 0 ? (
<p className="text-sm text-neutral-500 text-center py-4">{t('admin.noRecentActivity')}</p>
) : (
recentActivity.slice(0, 5).map((activity) => {
// Get color based on activity type
const getActivityColor = (type: string) => {
const colors: Record<string, string> = {
'event_created': 'bg-green-500',
'photos_uploaded': 'bg-blue-500',
'event_archived': 'bg-purple-500',
'archive_restored': 'bg-indigo-500',
'archive_deleted': 'bg-red-500',
'bulk_download': 'bg-blue-500',
'email_config_updated': 'bg-yellow-500',
'branding_updated': 'bg-pink-500',
'theme_updated': 'bg-purple-500',
'gallery_password_entry': 'bg-gray-500',
};
return colors[type] || 'bg-gray-500';
};
// Format activity message with translations
const getActivityMessage = (): string => {
const translationKey = `admin.activities.${activity.type}`;
const params: Record<string, any> = {
eventName: activity.eventName || t('common.unknown'),
count: activity.metadata?.count || 0,
template: activity.metadata?.template_key || '',
categoryName: activity.metadata?.category_name || ''
};
// Check if translation exists
const translated = t(translationKey, params);
if (typeof translated === 'string') {
return translated;
}
// Fallback to unknown activity if translation not found
return t('admin.activities.unknown') as string;
};
return (
<div key={activity.id} className="flex items-start gap-3">
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${getActivityColor(activity.type)}`} />
<div className="flex-1 min-w-0">
<p className="text-sm text-neutral-900 break-words">
{getActivityMessage()}
</p>
<p className="text-xs text-neutral-500">{activity.actorName}</p>
<p className="text-xs text-neutral-400 mt-1">
{formatDistanceToNow(parseISO(activity.createdAt), { addSuffix: true })}
</p>
</div>
</div>
);
})
)}
</div>
</Card>
</div>
</div>
);
};
AdminDashboard.displayName = 'AdminDashboard';
+260
View File
@@ -0,0 +1,260 @@
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, ReCaptcha } from '../../components/common';
import { useAdminAuth } from '../../contexts';
import { authService } from '../../services/auth.service';
import { getAuthToken, api } from '../../config/api';
export const AdminLoginPage: React.FC = () => {
const { isAuthenticated, login } = useAdminAuth();
const [searchParams] = useSearchParams();
const [formData, setFormData] = useState({
email: '',
password: '',
});
const [showPassword, setShowPassword] = useState(false);
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('/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) {
return <Navigate to="/admin/dashboard" replace />;
}
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Invalid email format';
}
if (!formData.password) {
newErrors.password = 'Password is required';
} else if (formData.password.length < 6) {
newErrors.password = 'Password must be at least 6 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setIsLoading(true);
setErrors({});
try {
const response = await authService.adminLogin({
...formData,
recaptchaToken
});
login(response.token, response.user);
toast.success('Login successful!');
setLoginSuccess(true);
} catch (error: any) {
// Login error handled by UI notification
// Handle network errors gracefully
if (error.code === 'ERR_NETWORK' || error.code === 'ERR_CONNECTION_RESET') {
// Check if we actually got logged in despite the error
const token = getAuthToken(true);
if (token) {
// Login was successful, just had a connection issue
setLoginSuccess(true);
return;
}
toast.error('Network error. Please check your connection and try again.');
} else if (error.response?.status === 429) {
toast.error('Too many login attempts. Please try again later.');
} else if (error.response?.status === 401) {
setErrors({ form: 'Invalid email or password' });
} else {
toast.error('An error occurred. Please try again.');
}
} finally {
setIsLoading(false);
}
};
const handleInputChange = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, [field]: e.target.value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
return (
<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="w-[200px] h-[150px] mx-auto mb-6 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: '#eee6d2' }}
>
<img
src="/picpeak-logo-transparent.png"
alt="PicPeak"
className="w-[180px] h-[130px] object-contain"
/>
</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 */}
<Card padding="lg">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Form Error */}
{errors.form && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{errors.form}</p>
</div>
)}
{/* Email Field */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-neutral-700 mb-1">
Email Address
</label>
<Input
id="email"
type="email"
value={formData.email}
onChange={handleInputChange('email')}
error={errors.email}
placeholder="admin@example.com"
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
autoComplete="email"
autoFocus
/>
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
Password
</label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleInputChange('password')}
error={errors.password}
placeholder="Enter your password"
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600 transition-colors"
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
</div>
{/* Remember Me & Forgot Password */}
<div className="flex items-center justify-between">
<label className="flex items-center">
<input
type="checkbox"
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">Remember me</span>
</label>
<a href="#" className="text-sm text-primary-600 hover:text-primary-700">
Forgot password?
</a>
</div>
{/* reCAPTCHA */}
<ReCaptcha
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
/>
{/* Submit Button */}
<Button
type="submit"
variant="primary"
size="lg"
isLoading={isLoading}
className="w-full"
>
Sign In
</Button>
</form>
</Card>
{/* Footer */}
<div className="text-center mt-8">
<p className="text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
Need help? Contact{' '}
<a
href={`mailto:${settingsData?.branding_support_email || 'support@example.com'}`}
className="hover:underline"
style={{ color: 'var(--color-primary, #5C8762)' }}
>
{settingsData?.branding_support_email || 'support@example.com'}
</a>
</p>
<p className="text-xs mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
{/* Development Hint */}
{import.meta.env.DEV && (
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800 text-center">
<strong>Development Mode:</strong> Use email: admin@example.com, password: admin123
</p>
</div>
)}
</div>
</div>
);
};
AdminLoginPage.displayName = 'AdminLoginPage';
+423
View File
@@ -0,0 +1,423 @@
import React, { useState } from 'react';
import {
BarChart3,
TrendingUp,
Users,
Eye,
Download,
Smartphone,
Monitor,
Activity,
RefreshCw,
Tablet
} from 'lucide-react';
import { format, parseISO } from 'date-fns';
import { Button, Card, Loading } from '../../components/common';
import { useQuery } from '@tanstack/react-query';
import { adminService } from '../../services/admin.service';
import { useTranslation } from 'react-i18next';
// Map API response to component format
interface ComponentAnalyticsData {
pageViews: {
total: number;
trend: number;
chartData: Array<{ date: string; views: number }>;
};
uniqueVisitors: {
total: number;
trend: number;
chartData: Array<{ date: string; visitors: number }>;
};
downloads: {
total: number;
trend: number;
topGalleries: Array<{ name: string; downloads: number }>;
};
devices: {
desktop: number;
mobile: number;
tablet: number;
};
topPages: Array<{
path: string;
views: number;
uniqueVisitors: number;
}>;
}
export const AnalyticsPage: React.FC = () => {
const { t } = useTranslation();
const [dateRange, setDateRange] = useState<'7d' | '30d' | '90d'>('7d');
const [isEmbedMode, setIsEmbedMode] = useState(false);
// Check if Umami is configured
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
// const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
const umamiShareUrl = import.meta.env.VITE_UMAMI_SHARE_URL;
// Fetch analytics data from backend
const { data: apiData, isLoading, refetch } = useQuery({
queryKey: ['admin-analytics', dateRange],
queryFn: async () => {
const days = dateRange === '7d' ? 7 : dateRange === '30d' ? 30 : 90;
return adminService.getAnalytics(days);
},
refetchInterval: 60000 // Refresh every minute
});
// Fetch dashboard stats for additional metrics
const { data: dashboardStats } = useQuery({
queryKey: ['admin-dashboard-stats'],
queryFn: () => adminService.getDashboardStats(),
});
// Calculate trends and format data
const analytics: ComponentAnalyticsData | undefined = React.useMemo(() => {
if (!apiData) return undefined;
// Calculate totals from chart data
const totalViews = apiData.chartData.reduce((sum, day) => sum + day.views, 0);
const totalVisitors = apiData.chartData.reduce((sum, day) => sum + day.uniqueVisitors, 0);
const totalDownloads = apiData.chartData.reduce((sum, day) => sum + day.downloads, 0);
// Calculate trends (comparing last half to first half)
const halfPoint = Math.floor(apiData.chartData.length / 2);
const firstHalfViews = apiData.chartData.slice(0, halfPoint).reduce((sum, day) => sum + day.views, 0);
const secondHalfViews = apiData.chartData.slice(halfPoint).reduce((sum, day) => sum + day.views, 0);
const viewsTrend = firstHalfViews > 0 ? ((secondHalfViews - firstHalfViews) / firstHalfViews) * 100 : 0;
const firstHalfVisitors = apiData.chartData.slice(0, halfPoint).reduce((sum, day) => sum + day.uniqueVisitors, 0);
const secondHalfVisitors = apiData.chartData.slice(halfPoint).reduce((sum, day) => sum + day.uniqueVisitors, 0);
const visitorsTrend = firstHalfVisitors > 0 ? ((secondHalfVisitors - firstHalfVisitors) / firstHalfVisitors) * 100 : 0;
const firstHalfDownloads = apiData.chartData.slice(0, halfPoint).reduce((sum, day) => sum + day.downloads, 0);
const secondHalfDownloads = apiData.chartData.slice(halfPoint).reduce((sum, day) => sum + day.downloads, 0);
const downloadsTrend = firstHalfDownloads > 0 ? ((secondHalfDownloads - firstHalfDownloads) / firstHalfDownloads) * 100 : 0;
// Format top galleries for downloads
const topGalleriesWithDownloads = apiData.topGalleries.map(gallery => ({
name: gallery.event_name,
downloads: gallery.views // Using views as download count for now
}));
return {
pageViews: {
total: totalViews,
trend: Math.round(viewsTrend * 10) / 10,
chartData: apiData.chartData.map(d => ({ date: d.date, views: d.views }))
},
uniqueVisitors: {
total: totalVisitors,
trend: Math.round(visitorsTrend * 10) / 10,
chartData: apiData.chartData.map(d => ({ date: d.date, visitors: d.uniqueVisitors }))
},
downloads: {
total: totalDownloads,
trend: Math.round(downloadsTrend * 10) / 10,
topGalleries: topGalleriesWithDownloads
},
devices: apiData.devices,
topPages: apiData.topGalleries.map(gallery => ({
path: `/gallery/${gallery.slug}`,
views: gallery.views,
uniqueVisitors: Math.round(gallery.views * 0.4) // Estimate unique visitors
}))
};
}, [apiData]);
const renderTrendBadge = (trend: number) => {
const isPositive = trend > 0;
return (
<span className={`inline-flex items-center text-xs font-medium ${
isPositive ? 'text-green-700' : 'text-red-700'
}`}>
<TrendingUp className={`w-3 h-3 mr-1 ${!isPositive ? 'rotate-180' : ''}`} />
{Math.abs(trend)}%
</span>
);
};
const renderMiniChart = (data: Array<{ date: string; value: number }>, color: string) => {
const max = Math.max(...data.map(d => d.value));
const height = 40;
return (
<div className="flex items-end gap-1 h-10">
{data.map((item, index) => (
<div
key={index}
className={`flex-1 ${color} rounded-t opacity-70 hover:opacity-100 transition-opacity`}
style={{ height: `${(item.value / max) * height}px` }}
title={`${format(parseISO(item.date), 'MMM d')}: ${item.value}`}
/>
))}
</div>
);
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('analytics.loadingAnalytics')} />
</div>
);
}
// If Umami is configured and embed mode is enabled, show the Umami dashboard
if (isEmbedMode && umamiShareUrl) {
return (
<div>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-neutral-900">{t('analytics.title')}</h1>
<p className="text-neutral-600 mt-1">{t('analytics.detailedSubtitle')}</p>
</div>
<Button
variant="outline"
onClick={() => setIsEmbedMode(false)}
leftIcon={<BarChart3 className="w-4 h-4" />}
>
{t('analytics.showSummaryView')}
</Button>
</div>
<Card padding="none" className="overflow-hidden" style={{ height: '800px' }}>
<iframe
src={umamiShareUrl}
className="w-full h-full border-0"
title="Umami Analytics Dashboard"
/>
</Card>
</div>
);
}
return (
<div>
{/* Page Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-neutral-900">{t('analytics.title')}</h1>
<p className="text-neutral-600 mt-1">{t('analytics.subtitle')}</p>
</div>
<div className="flex items-center gap-3">
{umamiShareUrl && (
<Button
variant="outline"
onClick={() => setIsEmbedMode(true)}
leftIcon={<Activity className="w-4 h-4" />}
>
{t('analytics.fullDashboard')}
</Button>
)}
<Button
variant="outline"
onClick={() => refetch()}
leftIcon={<RefreshCw className="w-4 h-4" />}
>
{t('analytics.refresh')}
</Button>
<select
value={dateRange}
onChange={(e) => setDateRange(e.target.value as any)}
className="px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="7d">{t('analytics.last7Days')}</option>
<option value="30d">{t('analytics.last30Days')}</option>
<option value="90d">{t('analytics.last90Days')}</option>
</select>
</div>
</div>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<Card padding="md">
<div className="flex items-start justify-between mb-4">
<div>
<p className="text-sm text-neutral-600">{t('analytics.pageViews')}</p>
<p className="text-3xl font-bold text-neutral-900">{analytics?.pageViews.total.toLocaleString()}</p>
<div className="mt-1">
{renderTrendBadge(analytics?.pageViews.trend || 0)}
</div>
</div>
<Eye className="w-8 h-8 text-blue-600" />
</div>
{analytics?.pageViews.chartData && renderMiniChart(
analytics.pageViews.chartData.map(d => ({ date: d.date, value: d.views })),
'bg-blue-500'
)}
</Card>
<Card padding="md">
<div className="flex items-start justify-between mb-4">
<div>
<p className="text-sm text-neutral-600">{t('analytics.uniqueVisitors')}</p>
<p className="text-3xl font-bold text-neutral-900">{analytics?.uniqueVisitors.total.toLocaleString()}</p>
<div className="mt-1">
{renderTrendBadge(analytics?.uniqueVisitors.trend || 0)}
</div>
</div>
<Users className="w-8 h-8 text-green-600" />
</div>
{analytics?.uniqueVisitors.chartData && renderMiniChart(
analytics.uniqueVisitors.chartData.map(d => ({ date: d.date, value: d.visitors })),
'bg-green-500'
)}
</Card>
<Card padding="md">
<div className="flex items-start justify-between mb-4">
<div>
<p className="text-sm text-neutral-600">{t('analytics.totalDownloads')}</p>
<p className="text-3xl font-bold text-neutral-900">{analytics?.downloads.total.toLocaleString()}</p>
<div className="mt-1">
{renderTrendBadge(analytics?.downloads.trend || 0)}
</div>
</div>
<Download className="w-8 h-8 text-purple-600" />
</div>
<div className="mt-4 space-y-2">
<p className="text-xs text-neutral-500 uppercase">{t('analytics.topGallery')}</p>
<p className="text-sm font-medium text-neutral-900 truncate">
{analytics?.downloads.topGalleries[0]?.name}
</p>
</div>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Top Pages */}
<div className="lg:col-span-2">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('analytics.topPages')}</h2>
<div className="space-y-3">
{analytics?.topPages.map((page, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900">{page.path}</p>
<p className="text-xs text-neutral-500">
{page.uniqueVisitors} {t('analytics.visitors')}
</p>
</div>
<div className="text-right">
<p className="text-sm font-semibold text-neutral-900">{page.views}</p>
<p className="text-xs text-neutral-500">{t('analytics.views')}</p>
</div>
</div>
))}
</div>
</Card>
{/* Top Downloads */}
<Card padding="md" className="mt-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('analytics.topDownloadsByGallery')}</h2>
<div className="space-y-3">
{analytics?.downloads.topGalleries.map((gallery, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900">{gallery.name}</p>
</div>
<div className="flex items-center gap-4">
<div className="flex-1 bg-neutral-200 rounded-full h-2 max-w-[100px]">
<div
className="bg-purple-600 h-2 rounded-full"
style={{
width: `${(gallery.downloads / (analytics.downloads.topGalleries[0]?.downloads || 1)) * 100}%`
}}
/>
</div>
<p className="text-sm font-semibold text-neutral-900 w-12 text-right">
{gallery.downloads}
</p>
</div>
</div>
))}
</div>
</Card>
</div>
{/* Right Column */}
<div className="space-y-6">
{/* Device Breakdown */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('analytics.deviceBreakdown')}</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Monitor className="w-5 h-5 text-neutral-600" />
<span className="text-sm text-neutral-700">{t('analytics.desktop')}</span>
</div>
<span className="text-sm font-semibold">{analytics?.devices.desktop}%</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Smartphone className="w-5 h-5 text-neutral-600" />
<span className="text-sm text-neutral-700">{t('analytics.mobile')}</span>
</div>
<span className="text-sm font-semibold">{analytics?.devices.mobile}%</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Tablet className="w-5 h-5 text-neutral-600" />
<span className="text-sm text-neutral-700">{t('analytics.tablet')}</span>
</div>
<span className="text-sm font-semibold">{analytics?.devices.tablet}%</span>
</div>
</div>
</Card>
{/* Storage Information */}
{dashboardStats && (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('analytics.storageUsage')}</h2>
<div className="space-y-4">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-600">{t('analytics.used')}</span>
<span className="font-medium">{adminService.formatBytes(dashboardStats.storageUsed)}</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all"
style={{ width: `${Math.min((dashboardStats.storageUsed / (10 * 1024 * 1024 * 1024)) * 100, 100)}%` }}
/>
</div>
<p className="text-xs text-neutral-500 mt-1">
{Math.round((dashboardStats.storageUsed / (10 * 1024 * 1024 * 1024)) * 100)}% {t('analytics.of')} 10 GB
</p>
</div>
<div className="pt-2 border-t">
<div className="flex justify-between text-sm">
<span className="text-neutral-600">{t('analytics.totalPhotos')}</span>
<span className="font-medium">{dashboardStats.totalPhotos.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm mt-2">
<span className="text-neutral-600">{t('analytics.activeEvents')}</span>
<span className="font-medium">{dashboardStats.activeEvents}</span>
</div>
</div>
</div>
</Card>
)}
</div>
</div>
{/* Configuration Notice */}
{!umamiUrl && (
<Card padding="md" className="mt-6 bg-amber-50 border-amber-200">
<div className="flex items-start gap-3">
<Activity className="w-5 h-5 text-amber-600 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-amber-900">{t('analytics.notConfigured')}</p>
<p className="text-sm text-amber-700 mt-1">
{t('analytics.configureInstructions')}
</p>
</div>
</div>
</Card>
)}
</div>
);
};
+399
View File
@@ -0,0 +1,399 @@
import React, { useState } from 'react';
import {
Archive,
Download,
Search,
Calendar,
HardDrive,
FileArchive,
AlertCircle,
RotateCcw,
Trash2,
ChevronLeft,
ChevronRight
} from 'lucide-react';
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 { useTranslation } from 'react-i18next';
// import { useNavigate } from 'react-router-dom';
export const ArchivesPage: React.FC = () => {
const { t } = useTranslation();
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 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],
queryFn: () => archiveService.getArchives(currentPage, 20),
});
const archives = archivesData?.archives || [];
const filteredArchives = archives.filter(archive => {
if (filterType !== 'all' && archive.eventType !== filterType) {
return false;
}
if (searchTerm) {
const term = searchTerm.toLowerCase();
return archive.eventName.toLowerCase().includes(term);
}
return true;
}).sort((a, b) => {
switch (sortBy) {
case 'name':
return a.eventName.localeCompare(b.eventName);
case 'size':
return b.archiveSize - a.archiveSize;
case 'date':
default:
const dateA = a.archivedAt ? new Date(a.archivedAt).getTime() : 0;
const dateB = b.archivedAt ? new Date(b.archivedAt).getTime() : 0;
return dateB - dateA;
}
});
const getTotalSize = () => {
return archives.reduce((sum, archive) => sum + archive.archiveSize, 0);
};
// Mutations
const restoreMutation = useMutation({
mutationFn: (id: number) => archiveService.restoreArchive(id),
onSuccess: () => {
toast.success(t('archives.restoreSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin-archives'] });
},
onError: () => {
toast.error(t('errors.somethingWentWrong'));
}
});
const deleteMutation = useMutation({
mutationFn: (id: number) => archiveService.deleteArchive(id),
onSuccess: () => {
toast.success(t('archives.deleteSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin-archives'] });
},
onError: () => {
toast.error(t('errors.somethingWentWrong'));
}
});
const handleDownload = async (archive: typeof archives[0]) => {
try {
toast.info(t('gallery.downloading', { count: 1 }).replace('photo', 'archive'));
await archiveService.downloadArchive(archive.id, `${archive.slug}-archive.zip`);
toast.success(t('common.download'));
} catch (error) {
toast.error(t('errors.somethingWentWrong'));
}
};
const handleRestore = (archive: typeof archives[0]) => {
if (confirm(t('archives.confirmRestore').replace('{{name}}', archive.eventName))) {
restoreMutation.mutate(archive.id);
}
};
const handleDelete = (archive: typeof archives[0]) => {
if (confirm(t('archives.confirmDelete').replace('{{name}}', archive.eventName))) {
deleteMutation.mutate(archive.id);
}
};
// Details view not implemented yet
// const handleViewDetails = (archive: typeof archives[0]) => {
// navigate(`/admin/archives/${archive.id}`);
// };
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('archives.loadingArchives')} />
</div>
);
}
return (
<div>
{/* Page Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900">{t('archives.title')}</h1>
<p className="text-neutral-600 mt-1">{t('archives.subtitle')}</p>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{t('archives.totalArchives')}</p>
<p className="text-2xl font-bold text-neutral-900">{archives.length}</p>
</div>
<Archive className="w-8 h-8 text-primary-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{t('archives.storageUsed')}</p>
<p className="text-2xl font-bold text-neutral-900">{archiveService.formatBytes(getTotalSize())}</p>
</div>
<HardDrive className="w-8 h-8 text-blue-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{t('archives.totalPhotos')}</p>
<p className="text-2xl font-bold text-neutral-900">
{(() => {
const total = archives.reduce((sum, a) => sum + (parseInt(String(a.photoCount)) || 0), 0);
return total === 0 ? '0' : total.toLocaleString();
})()}
</p>
</div>
<FileArchive className="w-8 h-8 text-green-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{t('archives.avgArchiveSize')}</p>
<p className="text-2xl font-bold text-neutral-900">
{archives.length > 0
? archiveService.formatBytes(getTotalSize() / archives.length)
: '0 Bytes'
}
</p>
</div>
<Calendar className="w-8 h-8 text-purple-600" />
</div>
</Card>
</div>
{/* Filters and Search */}
<Card padding="sm" className="mb-6">
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1">
<Input
type="text"
placeholder={t('archives.searchPlaceholder')}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-2">
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="all">{t('archives.allTypes')}</option>
<option value="wedding">{t('archives.wedding')}</option>
<option value="birthday">{t('archives.birthday')}</option>
<option value="corporate">{t('archives.corporate')}</option>
<option value="party">Party</option>
<option value="other">{t('archives.other')}</option>
</select>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="date">{t('archives.sortByDate')}</option>
<option value="name">{t('archives.sortByName')}</option>
<option value="size">{t('archives.sortBySize')}</option>
</select>
</div>
</div>
</Card>
{/* Archives Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('archives.tableHeaders.event')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('archives.tableHeaders.type')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('archives.tableHeaders.archivedDate')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('archives.tableHeaders.size')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('archives.tableHeaders.photos')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('archives.tableHeaders.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-neutral-200">
{filteredArchives.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-neutral-500">
{t('archives.noArchivesFound')}
</td>
</tr>
) : (
filteredArchives.map((archive) => (
<tr key={archive.id} className="hover:bg-neutral-50">
<td className="px-6 py-4">
<div>
<p className="text-sm font-medium text-neutral-900">{archive.eventName}</p>
<p className="text-xs text-neutral-500">
{t('archives.eventDateNA').replace('N/A', formatDate(archive.eventDate, 'MMM d, yyyy') || 'N/A')}
</p>
</div>
</td>
<td className="px-6 py-4 text-sm text-neutral-700 capitalize">
{archive.eventType}
</td>
<td className="px-6 py-4 text-sm text-neutral-700">
<div>
<p>{formatDate(archive.archivedAt, 'MMM d, yyyy') || t('archives.processing')}</p>
<p className="text-xs text-neutral-500">
{formatDate(archive.archivedAt, 'h:mm a')}
</p>
</div>
</td>
<td className="px-6 py-4 text-sm text-neutral-700">
{archiveService.formatBytes(archive.archiveSize)}
</td>
<td className="px-6 py-4 text-sm text-neutral-700">
{archive.photoCount}
</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"
onClick={() => handleViewDetails(archive)}
leftIcon={<Eye className="w-4 h-4" />}
>
Details
</Button>
*/}
<Button
variant="ghost"
size="sm"
onClick={() => handleDownload(archive)}
leftIcon={<Download className="w-4 h-4" />}
disabled={!archive.archivePath}
>
{t('archives.download')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRestore(archive)}
leftIcon={<RotateCcw className="w-4 h-4" />}
disabled={restoreMutation.isPending}
>
{t('archives.restore')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(archive)}
leftIcon={<Trash2 className="w-4 h-4" />}
className="text-red-600 hover:text-red-700"
disabled={deleteMutation.isPending}
>
{t('archives.delete')}
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
{/* Pagination */}
{archivesData?.pagination && archivesData.pagination.totalPages > 1 && (
<div className="mt-6 flex items-center justify-between">
<div className="text-sm text-neutral-600">
{t('archives.showing', {
from: ((currentPage - 1) * archivesData.pagination.limit) + 1,
to: Math.min(currentPage * archivesData.pagination.limit, archivesData.pagination.total),
total: archivesData.pagination.total
})}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
leftIcon={<ChevronLeft className="w-4 h-4" />}
>
{t('common.previous')}
</Button>
<span className="px-3 text-sm">
{t('archives.page', { current: currentPage, total: archivesData.pagination.totalPages })}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.min(archivesData.pagination.totalPages, prev + 1))}
disabled={currentPage === archivesData.pagination.totalPages}
rightIcon={<ChevronRight className="w-4 h-4" />}
>
{t('common.next')}
</Button>
</div>
</div>
)}
{/* Storage Warning */}
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-amber-900">{t('archives.storageManagement')}</p>
<p className="text-sm text-amber-700 mt-1">
{t('archives.storageInfo')}
</p>
</div>
</div>
</div>
</div>
);
};
+536
View File
@@ -0,0 +1,536 @@
import React, { useState, useEffect, useRef } from 'react';
import { Save, Eye, Palette, Upload } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Input, ErrorBoundary, Loading } from '../../components/common';
import { ThemeCustomizerEnhanced, GalleryPreview } from '../../components/admin';
import { useTheme, type ThemeConfig, GALLERY_THEME_PRESETS } from '../../contexts/ThemeContext';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsService, type BrandingSettings } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
import { buildResourceUrl } from '../../utils/url';
export const BrandingPage: React.FC = () => {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
const [brandingSettings, setBrandingSettings] = useState<BrandingSettings>({
company_name: '',
company_tagline: '',
footer_text: '© 2024 Your Company. All rights reserved.',
support_email: '',
watermark_enabled: false,
watermark_position: 'bottom-right',
watermark_opacity: 50,
watermark_size: 15,
watermark_logo_url: '',
favicon_url: '',
});
const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
const [currentThemeName, setCurrentThemeName] = useState('default');
const [isPreviewMode, setIsPreviewMode] = useState(false);
const faviconInputRef = useRef<HTMLInputElement>(null);
// Fetch current settings
const { data: settings, isLoading } = useQuery({
queryKey: ['admin-settings', 'branding'],
queryFn: () => settingsService.getSettingsByType('branding'),
});
// Fetch theme settings
const { data: themeSettings } = useQuery({
queryKey: ['admin-settings', 'theme'],
queryFn: () => settingsService.getSettingsByType('theme'),
});
// Update branding mutation
const queryClient = useQueryClient();
const brandingMutation = useMutation({
mutationFn: settingsService.updateBranding,
onSuccess: () => {
toast.success(t('toast.brandingUpdated'));
// Invalidate all settings queries to refresh data
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
},
});
// Update theme mutation
const themeMutation = useMutation({
mutationFn: settingsService.updateTheme,
onSuccess: () => {
toast.success(t('toast.themeUpdated'));
},
onError: () => {
toast.error(t('toast.saveError'));
},
});
// Initialize settings from database
useEffect(() => {
if (settings) {
const formatted = settingsService.formatBrandingSettings(settings);
// Don't set logo_url here - it will be synced from theme
const { logo_url, ...brandingWithoutLogo } = formatted;
setBrandingSettings(prev => ({ ...prev, ...brandingWithoutLogo }));
}
}, [settings]);
// Initialize theme from database
useEffect(() => {
if (themeSettings) {
const formatted = settingsService.formatThemeSettings(themeSettings) as ThemeConfig;
if (formatted && Object.keys(formatted).length > 0) {
// Use the theme's logo URL as stored in the theme config
setCurrentTheme(formatted);
setTheme(formatted);
// Always sync the logo URL from theme to branding settings - theme is source of truth
setBrandingSettings(prev => ({ ...prev, logo_url: formatted.logoUrl || '' }));
// Try to identify which preset this matches
for (const [key, preset] of Object.entries(GALLERY_THEME_PRESETS)) {
if (JSON.stringify(preset.config) === JSON.stringify(formatted)) {
setCurrentThemeName(key);
break;
}
}
}
}
}, [themeSettings, setTheme]);
const handleBrandingChange = (key: string, value: any) => {
setBrandingSettings(prev => ({ ...prev, [key]: value }));
};
const handleThemeChange = (newTheme: ThemeConfig) => {
setCurrentTheme(newTheme);
// Also update logo URL in branding settings if it changed
if (newTheme.logoUrl !== currentTheme.logoUrl) {
setBrandingSettings(prev => ({ ...prev, logo_url: newTheme.logoUrl || '' }));
}
if (isPreviewMode) {
setTheme(newTheme);
}
};
const handlePresetChange = (presetName: string) => {
setCurrentThemeName(presetName);
// Get the preset theme config
const preset = GALLERY_THEME_PRESETS[presetName];
if (preset) {
setCurrentTheme(preset.config);
if (isPreviewMode) {
setTheme(preset.config);
}
}
};
const handleFaviconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
try {
const faviconUrl = await settingsService.uploadFavicon(file);
setBrandingSettings(prev => ({ ...prev, favicon_url: faviconUrl }));
toast.success(t('toast.uploadSuccess'));
} catch (error) {
console.error('Failed to upload favicon:', error);
toast.error(t('toast.uploadError'));
}
}
};
const handleWatermarkLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
try {
const watermarkLogoUrl = await settingsService.uploadWatermarkLogo(file);
setBrandingSettings(prev => ({ ...prev, watermark_logo_url: watermarkLogoUrl }));
toast.success(t('toast.uploadSuccess'));
} catch (error) {
console.error('Failed to upload watermark logo:', error);
toast.error(t('toast.uploadError'));
}
}
};
const handleSave = async () => {
try {
// Sync logo URL from theme to branding settings
const updatedBrandingSettings = {
...brandingSettings,
logo_url: currentTheme.logoUrl || ''
};
// Save branding settings to database
await brandingMutation.mutateAsync(updatedBrandingSettings);
// Save theme settings to database
await themeMutation.mutateAsync(currentTheme);
// Apply theme globally
setTheme(currentTheme);
// Update local state to reflect saved values
setBrandingSettings(updatedBrandingSettings);
} catch (error) {
console.error('Failed to save settings:', error);
}
};
const handlePreview = () => {
const previewWindow = window.open('/gallery/preview', '_blank');
if (previewWindow) {
// Send theme data to preview window
setTimeout(() => {
previewWindow.postMessage({
type: 'THEME_PREVIEW',
theme: currentTheme,
branding: brandingSettings
}, window.location.origin);
}, 1000);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('branding.loadingBranding')} />
</div>
);
}
return (
<ErrorBoundary>
<div>
{/* Page Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-neutral-900">{t('branding.title')}</h1>
<p className="text-neutral-600 mt-1">{t('branding.subtitle')}</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
leftIcon={<Eye className="w-4 h-4" />}
onClick={handlePreview}
>
{t('branding.preview')}
</Button>
<Button
variant="primary"
leftIcon={<Save className="w-4 h-4" />}
onClick={handleSave}
>
{t('branding.saveChanges')}
</Button>
</div>
</div>
{/* Company Branding */}
<Card padding="md" className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('branding.companyInfo')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Input
label={t('branding.companyName')}
value={brandingSettings.company_name}
onChange={(e) => handleBrandingChange('company_name', e.target.value)}
placeholder={t('branding.companyName')}
helperText={t('branding.companyNameHelp')}
/>
<Input
label={t('branding.companyTagline')}
value={brandingSettings.company_tagline}
onChange={(e) => handleBrandingChange('company_tagline', e.target.value)}
placeholder={t('branding.companyTagline')}
helperText={t('branding.companyTaglineHelp')}
/>
<Input
label={t('branding.supportEmail')}
type="email"
value={brandingSettings.support_email}
onChange={(e) => handleBrandingChange('support_email', e.target.value)}
placeholder="support@yourcompany.com"
helperText={t('branding.supportEmailHelp')}
/>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.footerText')}
</label>
<textarea
value={brandingSettings.footer_text}
onChange={(e) => handleBrandingChange('footer_text', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
rows={2}
placeholder="© 2024 Your Company. All rights reserved."
/>
</div>
</div>
<div className="mt-6 pt-6 border-t border-neutral-200">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.favicon')}
</label>
<div className="space-y-2">
{brandingSettings.favicon_url && (
<div className="flex items-center gap-2">
<img
src={brandingSettings.favicon_url.startsWith('http') ? brandingSettings.favicon_url : buildResourceUrl(brandingSettings.favicon_url)}
alt="Current favicon"
className="w-8 h-8"
/>
<span className="text-sm text-neutral-600">{t('branding.currentFavicon')}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleBrandingChange('favicon_url', '')}
>
{t('branding.removeFavicon')}
</Button>
</div>
)}
<div>
<input
ref={faviconInputRef}
type="file"
accept="image/png,image/x-icon"
onChange={handleFaviconUpload}
className="hidden"
/>
<Button
variant="secondary"
size="sm"
onClick={() => faviconInputRef.current?.click()}
leftIcon={<Upload className="w-4 h-4" />}
>
{t('branding.uploadFavicon')}
</Button>
<p className="text-xs text-neutral-600 mt-1">{t('branding.faviconHelp')}</p>
</div>
</div>
</div>
</div>
<div className="mt-6 pt-6 border-t border-neutral-200">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={brandingSettings.watermark_enabled}
onChange={(e) => handleBrandingChange('watermark_enabled', e.target.checked)}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-900">{t('branding.enableWatermarks')}</span>
<p className="text-xs text-neutral-600">{t('branding.watermarkHelp')}</p>
</div>
</label>
</div>
{/* Watermark Settings */}
{brandingSettings.watermark_enabled && (
<div className="mt-6 space-y-6 border-t border-neutral-200 pt-6">
<h3 className="text-md font-semibold text-neutral-900">{t('branding.watermarkSettings')}</h3>
{/* Watermark Logo Upload */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.watermarkLogo')}
</label>
<div className="space-y-2">
{brandingSettings.watermark_logo_url && (
<div className="flex items-center gap-2">
<img
src={brandingSettings.watermark_logo_url.startsWith('http') ? brandingSettings.watermark_logo_url : buildResourceUrl(brandingSettings.watermark_logo_url)}
alt="Current watermark"
className="h-16 w-auto object-contain bg-neutral-100 p-2 rounded"
/>
<span className="text-sm text-neutral-600">{t('branding.currentWatermark')}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleBrandingChange('watermark_logo_url', '')}
>
{t('common.delete')}
</Button>
</div>
)}
<div>
<input
type="file"
accept="image/png"
onChange={handleWatermarkLogoUpload}
className="hidden"
id="watermark-upload"
/>
<label htmlFor="watermark-upload">
<Button
variant="secondary"
size="sm"
onClick={() => document.getElementById('watermark-upload')?.click()}
leftIcon={<Upload className="w-4 h-4" />}
>
{t('branding.uploadWatermarkLogo')}
</Button>
</label>
<p className="text-xs text-neutral-600 mt-1">{t('branding.watermarkHelp')}</p>
</div>
</div>
</div>
{/* Position Selector */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.watermarkPosition')}
</label>
<div className="grid grid-cols-3 gap-2 max-w-xs">
{[
{ value: 'top-left', label: t('branding.topLeft') },
{ value: 'top-right', label: t('branding.topRight') },
{ value: 'center', label: t('branding.center') },
{ value: 'bottom-left', label: t('branding.bottomLeft') },
{ value: 'bottom-right', label: t('branding.bottomRight') }
].map((position) => (
<button
key={position.value}
type="button"
onClick={() => handleBrandingChange('watermark_position', position.value)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
brandingSettings.watermark_position === position.value
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-50'
}`}
>
{position.label}
</button>
))}
</div>
</div>
{/* Opacity Slider */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.watermarkOpacity')}: {brandingSettings.watermark_opacity || 50}%
</label>
<input
type="range"
min="10"
max="100"
step="10"
value={brandingSettings.watermark_opacity || 50}
onChange={(e) => handleBrandingChange('watermark_opacity', parseInt(e.target.value))}
className="w-full slider"
style={{
WebkitAppearance: 'none',
appearance: 'none',
height: '8px',
background: '#d4d4d4',
borderRadius: '4px',
outline: 'none'
}}
/>
<div className="flex justify-between text-xs text-neutral-500 mt-1">
<span>10%</span>
<span>50%</span>
<span>100%</span>
</div>
</div>
{/* Size Slider */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.watermarkSize')}: {brandingSettings.watermark_size || 15}%
</label>
<input
type="range"
min="5"
max="30"
step="5"
value={brandingSettings.watermark_size || 15}
onChange={(e) => handleBrandingChange('watermark_size', parseInt(e.target.value))}
className="w-full slider"
style={{
WebkitAppearance: 'none',
appearance: 'none',
height: '8px',
background: '#d4d4d4',
borderRadius: '4px',
outline: 'none'
}}
/>
<div className="flex justify-between text-xs text-neutral-500 mt-1">
<span>5%</span>
<span>15%</span>
<span>30%</span>
</div>
</div>
</div>
)}
</Card>
{/* Theme Customization */}
<div className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Palette className="w-5 h-5" />
{t('branding.galleryTheme')}
</h2>
<div className="mb-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isPreviewMode}
onChange={(e) => setIsPreviewMode(e.target.checked)}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-neutral-700">{t('branding.applyLivePreview')}</span>
</label>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left side - Theme Customizer */}
<div>
<ThemeCustomizerEnhanced
value={currentTheme}
onChange={handleThemeChange}
presetName={currentThemeName}
onPresetChange={handlePresetChange}
isPreviewMode={isPreviewMode}
showGalleryLayouts={true}
hideActions={true}
/>
</div>
{/* Right side - Gallery Preview */}
<div className="lg:sticky lg:top-4 lg:h-fit">
<Card className="p-4">
<h3 className="text-sm font-medium text-neutral-700 mb-3">
{t('branding.livePreview')}
</h3>
<GalleryPreview
theme={currentTheme}
className="shadow-lg"
/>
</Card>
</div>
</div>
</div>
{/* Event-Specific Themes Info */}
<Card padding="md" className="bg-blue-50 border-blue-200">
<div className="flex items-start gap-3">
<Palette className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-sm font-medium text-blue-900">{t('branding.eventSpecificThemes')}</h3>
<p className="text-sm text-blue-700 mt-1">
{t('branding.eventThemesInfo')}
</p>
</div>
</div>
</Card>
</div>
</ErrorBoundary>
);
};
+203
View File
@@ -0,0 +1,203 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Save, FileText, Globe } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card, Input, Loading } from '../../components/common';
import { CMSEditor } from '../../components/admin/CMSEditor';
import { cmsService } from '../../services/cms.service';
import type { CMSPage as CMSPageType } from '../../services/cms.service';
export const CMSPage: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [selectedPage, setSelectedPage] = useState<string>('impressum');
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
const [editForm, setEditForm] = useState<Partial<CMSPageType>>({});
// Fetch CMS pages
const { data: pages, isLoading } = useQuery({
queryKey: ['cms-pages'],
queryFn: cmsService.getPages,
});
// Update page mutation
const updateMutation = useMutation({
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
cmsService.updatePage(slug, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cms-pages'] });
toast.success(t('cms.pageUpdated'));
},
onError: () => {
toast.error(t('toast.saveError'));
},
});
// Load page data when selection changes
React.useEffect(() => {
if (pages) {
const page = pages.find(p => p.slug === selectedPage);
if (page) {
setEditForm(page);
}
}
}, [pages, selectedPage]);
const handleSave = () => {
updateMutation.mutate({
slug: selectedPage,
data: editForm,
});
};
const handleContentChange = (content: string) => {
const field = editingLang === 'de' ? 'content_de' : 'content_en';
setEditForm(prev => ({ ...prev, [field]: content }));
};
const handleTitleChange = (title: string) => {
const field = editingLang === 'de' ? 'title_de' : 'title_en';
setEditForm(prev => ({ ...prev, [field]: title }));
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('cms.loadingPages')} />
</div>
);
}
const currentPage = pages?.find(p => p.slug === selectedPage);
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900">{t('cms.title')}</h1>
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Page Selection */}
<div className="lg:col-span-1">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('cms.pages')}</h2>
<div className="space-y-2">
{pages?.map((page) => (
<button
key={page.slug}
onClick={() => setSelectedPage(page.slug)}
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
selectedPage === page.slug
? 'bg-primary-100 text-primary-700 border border-primary-300'
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
}`}
>
<FileText className="w-5 h-5" />
<div>
<p className="font-medium">{t(`legal.${page.slug}`)}</p>
<p className="text-sm text-neutral-500">/{page.slug}</p>
</div>
</button>
))}
</div>
</Card>
<Card padding="md" className="mt-4">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">{t('cms.previewLinks')}</h3>
<div className="space-y-2 text-sm">
<a
href={`${window.location.origin}/${selectedPage}?lang=en`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
>
<Globe className="w-4 h-4" />
{t('cms.englishVersion')}
</a>
<a
href={`${window.location.origin}/${selectedPage}?lang=de`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
>
<Globe className="w-4 h-4" />
{t('cms.germanVersion')}
</a>
</div>
</Card>
</div>
{/* Editor */}
<div className="lg:col-span-3">
<Card padding="md">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-neutral-900">
{t('cms.editPage', { page: t(`legal.${selectedPage}`) })}
</h2>
{/* Language Tabs */}
<div className="flex gap-2">
<button
onClick={() => setEditingLang('en')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'en'
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
🇬🇧 English
</button>
<button
onClick={() => setEditingLang('de')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'de'
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
🇩🇪 Deutsch
</button>
</div>
</div>
<div className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('cms.pageTitle')} ({editingLang === 'en' ? 'English' : 'German'})
</label>
<Input
value={editingLang === 'en' ? editForm.title_en || '' : editForm.title_de || ''}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder={t('cms.pageTitlePlaceholder')}
/>
</div>
{/* Content */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('cms.pageContent')} ({editingLang === 'en' ? 'English' : 'German'})
</label>
<CMSEditor
content={editingLang === 'en' ? editForm.content_en || '' : editForm.content_de || ''}
onChange={handleContentChange}
onSave={handleSave}
isSaving={updateMutation.isPending}
/>
</div>
</div>
{currentPage?.updated_at && (
<p className="text-xs text-neutral-500 mt-4">
{t('cms.lastUpdated')} {new Date(currentPage.updated_at).toLocaleString()}
</p>
)}
</Card>
</div>
</div>
</div>
);
};
@@ -0,0 +1,290 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Save, FileText, Globe, Clock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { debounce } from 'lodash';
import { Button, Card, Input, Loading } from '../../components/common';
import { CMSEditor } from '../../components/admin/CMSEditor';
import { cmsService } from '../../services/cms.service';
import type { CMSPage as CMSPageType } from '../../services/cms.service';
export const CMSPageEnhanced: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [selectedPage, setSelectedPage] = useState<string>('impressum');
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
const [editForm, setEditForm] = useState<Partial<CMSPageType>>({});
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [isAutoSaving, setIsAutoSaving] = useState(false);
// Fetch CMS pages
const { data: pages, isLoading } = useQuery({
queryKey: ['cms-pages'],
queryFn: cmsService.getPages,
});
// Update page mutation
const updateMutation = useMutation({
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
cmsService.updatePage(slug, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cms-pages'] });
setHasUnsavedChanges(false);
setLastSaved(new Date());
setIsAutoSaving(false);
if (!isAutoSaving) {
toast.success(t('cms.pageUpdated'));
}
},
onError: () => {
setIsAutoSaving(false);
toast.error(t('toast.saveError'));
},
});
// Auto-save functionality
const autoSave = useCallback(
debounce(() => {
if (hasUnsavedChanges && !updateMutation.isPending) {
setIsAutoSaving(true);
updateMutation.mutate({
slug: selectedPage,
data: editForm,
});
}
}, 3000),
[hasUnsavedChanges, editForm, selectedPage]
);
// Trigger auto-save when content changes
useEffect(() => {
if (hasUnsavedChanges) {
autoSave();
}
return () => {
autoSave.cancel();
};
}, [hasUnsavedChanges, autoSave]);
// Load page data when selection changes
React.useEffect(() => {
if (pages) {
const page = pages.find(p => p.slug === selectedPage);
if (page) {
setEditForm(page);
setHasUnsavedChanges(false);
}
}
}, [pages, selectedPage]);
const handleSave = () => {
autoSave.cancel(); // Cancel any pending auto-save
updateMutation.mutate({
slug: selectedPage,
data: editForm,
});
};
const handleContentChange = (content: string) => {
const field = editingLang === 'de' ? 'content_de' : 'content_en';
setEditForm(prev => ({ ...prev, [field]: content }));
setHasUnsavedChanges(true);
};
const handleTitleChange = (title: string) => {
const field = editingLang === 'de' ? 'title_de' : 'title_en';
setEditForm(prev => ({ ...prev, [field]: title }));
setHasUnsavedChanges(true);
};
// Warn before leaving with unsaved changes
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('cms.loadingPages')} />
</div>
);
}
const currentPage = pages?.find(p => p.slug === selectedPage);
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900">{t('cms.title')}</h1>
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Page Selection */}
<div className="lg:col-span-1">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('cms.pages')}</h2>
<div className="space-y-2">
{pages?.map((page) => (
<button
key={page.slug}
onClick={() => {
if (hasUnsavedChanges) {
if (confirm('You have unsaved changes. Do you want to save them?')) {
handleSave();
}
}
setSelectedPage(page.slug);
}}
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
selectedPage === page.slug
? 'bg-primary-100 text-primary-700 border border-primary-300'
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
}`}
>
<FileText className="w-5 h-5" />
<div className="flex-1">
<p className="font-medium">{t(`legal.${page.slug}`)}</p>
<p className="text-sm text-neutral-500">/{page.slug}</p>
</div>
{selectedPage === page.slug && hasUnsavedChanges && (
<div className="w-2 h-2 bg-yellow-500 rounded-full" />
)}
</button>
))}
</div>
</Card>
<Card padding="md" className="mt-4">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">{t('cms.previewLinks')}</h3>
<div className="space-y-2 text-sm">
<a
href={`${window.location.origin}/${selectedPage}?lang=en`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
>
<Globe className="w-4 h-4" />
{t('cms.englishVersion')}
</a>
<a
href={`${window.location.origin}/${selectedPage}?lang=de`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
>
<Globe className="w-4 h-4" />
{t('cms.germanVersion')}
</a>
</div>
</Card>
{/* Auto-save status */}
{(hasUnsavedChanges || lastSaved) && (
<Card padding="md" className="mt-4">
<div className="text-sm">
{isAutoSaving && (
<div className="flex items-center gap-2 text-neutral-600">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Auto-saving...
</div>
)}
{!isAutoSaving && hasUnsavedChanges && (
<div className="flex items-center gap-2 text-yellow-600">
<div className="w-2 h-2 bg-yellow-500 rounded-full" />
Unsaved changes
</div>
)}
{!hasUnsavedChanges && lastSaved && (
<div className="flex items-center gap-2 text-green-600">
<Clock className="w-4 h-4" />
Saved {new Date(lastSaved).toLocaleTimeString()}
</div>
)}
</div>
</Card>
)}
</div>
{/* Editor */}
<div className="lg:col-span-3">
<Card padding="md">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-neutral-900">
{t('cms.editPage', { page: t(`legal.${selectedPage}`) })}
</h2>
{/* Language Tabs */}
<div className="flex gap-2">
<button
onClick={() => setEditingLang('en')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'en'
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
🇬🇧 English
</button>
<button
onClick={() => setEditingLang('de')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'de'
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
🇩🇪 Deutsch
</button>
</div>
</div>
<div className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('cms.pageTitle')} ({editingLang === 'en' ? 'English' : 'German'})
</label>
<Input
value={editingLang === 'en' ? editForm.title_en || '' : editForm.title_de || ''}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder={t('cms.pageTitlePlaceholder')}
/>
</div>
{/* Content */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('cms.pageContent')} ({editingLang === 'en' ? 'English' : 'German'})
</label>
<CMSEditor
content={editingLang === 'en' ? editForm.content_en || '' : editForm.content_de || ''}
onChange={handleContentChange}
onSave={handleSave}
isSaving={updateMutation.isPending}
/>
</div>
</div>
{currentPage?.updated_at && (
<p className="text-xs text-neutral-500 mt-4">
{t('cms.lastUpdated')} {new Date(currentPage.updated_at).toLocaleString()}
</p>
)}
</Card>
</div>
</div>
</div>
);
};
@@ -0,0 +1,611 @@
import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Calendar,
Mail,
Lock,
Clock,
ArrowLeft,
Info,
Upload,
Eye,
EyeOff
} from 'lucide-react';
import { format, addDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card } from '../../components/common';
import { useMutation, useQuery } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { categoriesService } from '../../services/categories.service';
import { useTranslation } from 'react-i18next';
interface FormData {
event_type: string;
event_name: string;
event_date: string;
host_email: string;
admin_email: string;
password: string;
confirm_password: string;
welcome_message: string;
color_theme: string;
expires_in_days: number;
allow_user_uploads: boolean;
upload_category_id: number | null;
}
const EVENT_TYPES = [
{ value: 'wedding', labelKey: 'events.types.wedding', emoji: '💒' },
{ value: 'birthday', labelKey: 'events.types.birthday', emoji: '🎂' },
{ value: 'corporate', labelKey: 'events.types.corporate', emoji: '🏢' },
{ value: 'other', labelKey: 'events.types.other', emoji: '📸' },
];
const COLOR_THEMES = [
{
value: 'default',
labelKey: 'events.themes.default',
color: 'bg-primary-600',
theme: {
primaryColor: '#5C8762',
accentColor: '#22c55e',
backgroundColor: '#fafafa',
textColor: '#171717',
borderRadius: 'md' as const
}
},
{
value: 'blue',
labelKey: 'events.themes.oceanBlue',
color: 'bg-blue-600',
theme: {
primaryColor: '#2563eb',
accentColor: '#3b82f6',
backgroundColor: '#f0f9ff',
textColor: '#0f172a',
borderRadius: 'md' as const
}
},
{
value: 'purple',
labelKey: 'events.themes.royalPurple',
color: 'bg-purple-600',
theme: {
primaryColor: '#9333ea',
accentColor: '#a855f7',
backgroundColor: '#faf5ff',
textColor: '#1e1b4b',
borderRadius: 'md' as const
}
},
{
value: 'rose',
labelKey: 'events.themes.roseGold',
color: 'bg-rose-600',
theme: {
primaryColor: '#e11d48',
accentColor: '#f43f5e',
backgroundColor: '#fff1f2',
textColor: '#881337',
borderRadius: 'md' as const
}
},
{
value: 'amber',
labelKey: 'events.themes.sunsetAmber',
color: 'bg-amber-600',
theme: {
primaryColor: '#d97706',
accentColor: '#f59e0b',
backgroundColor: '#fffbeb',
textColor: '#451a03',
borderRadius: 'md' as const
}
},
];
export const CreateEventPage: React.FC = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const isMountedRef = useRef(true);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
const [formData, setFormData] = useState<FormData>({
event_type: 'wedding',
event_name: '',
event_date: format(new Date(), 'yyyy-MM-dd'),
host_email: '',
admin_email: '',
password: '',
confirm_password: '',
welcome_message: '',
color_theme: 'default',
expires_in_days: 30,
allow_user_uploads: false,
upload_category_id: null,
});
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
const [showPassword, setShowPassword] = useState(false);
// Fetch categories for user upload selection
const { data: categories } = useQuery({
queryKey: ['categories', 'global'],
queryFn: () => categoriesService.getGlobalCategories()
});
const createMutation = useMutation({
mutationFn: eventsService.createEvent,
onSuccess: (data) => {
if (isMountedRef.current) {
toast.success(t('toast.eventCreated'));
// Add a small delay to ensure navigation works properly
setTimeout(() => {
if (isMountedRef.current && data?.id) {
navigate(`/admin/events/${data.id}`);
} else {
navigate('/admin/events');
}
}, 100);
}
},
onError: (error: any) => {
if (!isMountedRef.current) return;
if (error.code === 'ERR_NETWORK' || error.code === 'ERR_CONNECTION_RESET') {
toast.error(t('errors.networkError'));
} else if (error.response?.data?.errors) {
const newErrors: Record<string, string> = {};
error.response.data.errors.forEach((err: any) => {
newErrors[err.path] = err.msg;
});
setErrors(newErrors);
} else if (error.response?.status === 401) {
toast.error(t('errors.sessionExpired'));
navigate('/admin/login');
} else {
const errorMessage = error.response?.data?.error;
// Check if it's the password security requirements error
if (errorMessage === 'Password does not meet security requirements') {
toast.error(t('validation.passwordSecurityRequirements'));
} else {
toast.error(errorMessage || t('errors.failedToCreateEvent'));
}
}
},
});
const validateForm = (): boolean => {
const newErrors: Partial<Record<keyof FormData, string>> = {};
if (!formData.event_name.trim()) {
newErrors.event_name = t('validation.eventNameRequired');
}
if (!formData.host_email) {
newErrors.host_email = t('validation.hostEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) {
newErrors.host_email = t('validation.invalidEmailFormat');
}
if (!formData.admin_email) {
newErrors.admin_email = t('validation.adminEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
newErrors.admin_email = t('validation.invalidEmailFormat');
}
if (!formData.password) {
newErrors.password = t('validation.passwordRequired');
} else if (formData.password.length < 6) {
newErrors.password = t('validation.passwordMinLength');
} else if (/^\d{1,6}$/.test(formData.password)) {
// Prevent simple numeric passwords like "123456"
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
}
if (formData.password !== formData.confirm_password) {
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
}
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
newErrors.expires_in_days = t('validation.expirationRange');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
const selectedTheme = COLOR_THEMES.find(t => t.value === formData.color_theme);
createMutation.mutate({
event_type: formData.event_type,
event_name: formData.event_name,
event_date: formData.event_date,
host_email: formData.host_email,
admin_email: formData.admin_email,
password: formData.password,
welcome_message: formData.welcome_message || '',
color_theme: selectedTheme ? JSON.stringify(selectedTheme.theme) : undefined,
expiration_days: formData.expires_in_days,
allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id,
});
};
const handleInputChange = (field: keyof FormData) => (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const value = field === 'expires_in_days' ? parseInt(e.target.value) || 0 : e.target.value;
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user types
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
return (
<div className="max-w-4xl mx-auto">
{/* 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>
<h1 className="text-2xl font-bold text-neutral-900">{t('events.createNewEvent')}</h1>
<p className="text-neutral-600 mt-1">{t('events.createNewEventSubtitle')}</p>
</div>
<form onSubmit={handleSubmit}>
{/* Event Details */}
<Card padding="md" className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.eventDetails')}</h2>
<div className="space-y-4">
{/* Event Type */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.eventType')}
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{EVENT_TYPES.map(type => (
<button
key={type.value}
type="button"
onClick={() => setFormData(prev => ({ ...prev, event_type: type.value }))}
className={`p-3 rounded-lg border-2 transition-all ${
formData.event_type === type.value
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="text-2xl mb-1">{type.emoji}</div>
<div className="text-sm font-medium">{t(type.labelKey)}</div>
</button>
))}
</div>
</div>
{/* Event Name */}
<div>
<label htmlFor="event_name" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.eventName')}
</label>
<Input
id="event_name"
type="text"
value={formData.event_name}
onChange={handleInputChange('event_name')}
error={errors.event_name}
placeholder={t('events.eventNamePlaceholder')}
leftIcon={<Calendar className="w-5 h-5 text-neutral-400" />}
/>
</div>
{/* Event Date */}
<div>
<label htmlFor="event_date" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.eventDate')}
</label>
<Input
id="event_date"
type="date"
value={formData.event_date}
onChange={handleInputChange('event_date')}
error={errors.event_date}
/>
</div>
{/* Welcome Message */}
<div>
<label htmlFor="welcome_message" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.welcomeMessageOptional')}
</label>
<textarea
id="welcome_message"
value={formData.welcome_message}
onChange={handleInputChange('welcome_message')}
placeholder={t('events.welcomeMessagePlaceholder')}
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}
/>
</div>
</div>
</Card>
{/* Contact Information */}
<Card padding="md" className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.contactInformation')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Host Email */}
<div>
<label htmlFor="host_email" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.hostEmail')}
</label>
<Input
id="host_email"
type="email"
value={formData.host_email}
onChange={handleInputChange('host_email')}
error={errors.host_email}
placeholder={t('events.hostEmailPlaceholder')}
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('events.hostEmailHelp')}
</p>
</div>
{/* Admin Email */}
<div>
<label htmlFor="admin_email" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.adminNotificationEmail')}
</label>
<Input
id="admin_email"
type="email"
value={formData.admin_email}
onChange={handleInputChange('admin_email')}
error={errors.admin_email}
placeholder={t('events.adminEmailPlaceholder')}
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('events.adminEmailHelp')}
</p>
</div>
</div>
</Card>
{/* Security & Access */}
<Card padding="md" className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.securityAndAccess')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.galleryPassword')}
</label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleInputChange('password')}
error={errors.password}
placeholder={t('events.enterPassword')}
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
style={{ top: errors.password ? '0' : '0' }}
>
{showPassword ? (
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
) : (
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
)}
</button>
</div>
</div>
{/* Confirm Password */}
<div>
<label htmlFor="confirm_password" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.confirmPassword')}
</label>
<div className="relative">
<Input
id="confirm_password"
type={showPassword ? 'text' : 'password'}
value={formData.confirm_password}
onChange={handleInputChange('confirm_password')}
error={errors.confirm_password}
placeholder={t('events.confirmPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
style={{ top: errors.confirm_password ? '0' : '0' }}
>
{showPassword ? (
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
) : (
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
)}
</button>
</div>
</div>
</div>
</Card>
{/* Gallery Settings */}
<Card padding="md" className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.gallerySettings')}</h2>
<div className="space-y-4">
{/* Color Theme */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.colorTheme')}
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{COLOR_THEMES.map(theme => (
<button
key={theme.value}
type="button"
onClick={() => setFormData(prev => ({ ...prev, color_theme: theme.value }))}
className={`p-3 rounded-lg border-2 transition-all ${
formData.color_theme === theme.value
? 'border-primary-600 ring-2 ring-primary-200'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className={`w-full h-8 ${theme.color} rounded mb-2`} />
<div className="text-xs font-medium">{t(theme.labelKey)}</div>
</button>
))}
</div>
</div>
{/* Expiration */}
<div>
<label htmlFor="expires_in_days" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.galleryExpiresIn')}
</label>
<div className="flex items-center gap-4">
<Input
id="expires_in_days"
type="number"
value={formData.expires_in_days}
onChange={handleInputChange('expires_in_days')}
error={errors.expires_in_days}
min="1"
max="365"
className="w-32"
leftIcon={<Clock className="w-5 h-5 text-neutral-400" />}
/>
<span className="text-sm text-neutral-700">{t('common.days')}</span>
</div>
<div className="mt-2 p-3 bg-blue-50 rounded-lg flex items-start gap-2">
<Info className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800">
<p>{t('events.galleryExpiresOn', { date: format(addDays(new Date(), formData.expires_in_days), 'MMMM d, yyyy') })}</p>
<p className="mt-1">{t('events.guestsWillReceiveWarning')}</p>
</div>
</div>
</div>
</div>
</Card>
{/* User Upload Settings */}
<Card padding="md" className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.userUploads')}</h2>
<div className="space-y-4">
{/* Allow User Uploads */}
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={formData.allow_user_uploads}
onChange={(e) => setFormData(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>
{/* Upload Category Selection */}
{formData.allow_user_uploads && (
<div>
<label htmlFor="upload_category" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.uploadCategory')}
</label>
<select
id="upload_category"
value={formData.upload_category_id || ''}
onChange={(e) => setFormData(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: any) => (
<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>
)}
{formData.allow_user_uploads && (
<div className="mt-2 p-3 bg-amber-50 rounded-lg flex items-start gap-2">
<Upload className="w-5 h-5 text-amber-600 flex-shrink-0" />
<div className="text-sm text-amber-800">
<p>{t('events.userUploadWarning')}</p>
</div>
</div>
)}
</div>
</Card>
{/* Submit Buttons */}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate('/admin/events')}
>
{t('common.cancel')}
</Button>
<Button
type="submit"
variant="primary"
isLoading={createMutation.isPending}
>
{t('events.createEvent')}
</Button>
</div>
</form>
</div>
);
};
CreateEventPage.displayName = 'CreateEventPage';
@@ -0,0 +1,576 @@
import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Calendar,
Mail,
Lock,
Clock,
ArrowLeft,
Palette,
Eye,
EyeOff
} from 'lucide-react';
import { format, addDays } from 'date-fns';
import { enUS, de } from 'date-fns/locale';
import { toast } from 'react-toastify';
import { Button, Input, Card } from '../../components/common';
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor } from '../../components/admin';
import { useMutation, useQuery } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
interface FormData {
event_type: string;
event_name: string;
event_date: string;
host_name: string;
host_email: string;
admin_email: string;
password: string;
confirm_password: string;
welcome_message: string;
theme_preset: string;
theme_config: ThemeConfig;
expires_in_days: number;
allow_user_uploads: boolean;
upload_category_id: number | null;
}
const EVENT_TYPE_PRESETS: Record<string, string> = {
wedding: 'elegantWedding',
birthday: 'birthdayFun',
corporate: 'corporateTimeline',
other: 'default'
};
const EVENT_TYPES = [
{ value: 'wedding', labelKey: 'events.types.wedding', emoji: '💒' },
{ value: 'birthday', labelKey: 'events.types.birthday', emoji: '🎂' },
{ value: 'corporate', labelKey: 'events.types.corporate', emoji: '🏢' },
{ value: 'other', labelKey: 'events.types.other', emoji: '📸' },
];
export const CreateEventPageEnhanced: React.FC = () => {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const isMountedRef = useRef(true);
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
// const [showPreview, setShowPreview] = useState(false);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
const [formData, setFormData] = useState<FormData>({
event_type: 'wedding',
event_name: '',
event_date: format(new Date(), 'yyyy-MM-dd'),
host_name: '',
host_email: '',
admin_email: '',
password: '',
confirm_password: '',
welcome_message: '',
theme_preset: 'elegantWedding',
theme_config: GALLERY_THEME_PRESETS.elegantWedding.config,
expires_in_days: 30,
allow_user_uploads: false,
upload_category_id: null,
});
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
const [showPassword, setShowPassword] = useState(false);
// Fetch categories for user upload selection
const { data: categories } = useQuery({
queryKey: ['categories', 'global'],
queryFn: () => categoriesService.getGlobalCategories()
});
// Fetch default settings
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings()
});
// Update default expiration days when settings are loaded
useEffect(() => {
if (settings?.general_default_expiration_days) {
setFormData(prev => ({
...prev,
expires_in_days: settings.general_default_expiration_days
}));
}
}, [settings]);
// Update theme when event type changes
useEffect(() => {
const recommendedPreset = EVENT_TYPE_PRESETS[formData.event_type];
if (recommendedPreset && GALLERY_THEME_PRESETS[recommendedPreset]) {
setFormData(prev => ({
...prev,
theme_preset: recommendedPreset,
theme_config: GALLERY_THEME_PRESETS[recommendedPreset].config
}));
}
}, [formData.event_type]);
const createMutation = useMutation({
mutationFn: eventsService.createEvent,
onSuccess: (data) => {
if (isMountedRef.current) {
toast.success(t('toast.eventCreated'));
navigate(`/admin/events/${data.id}`);
}
},
onError: (error: any) => {
console.error('Create event error:', error);
console.error('Error response:', error.response?.data);
console.error('Error status:', error.response?.status);
console.error('Full error object:', JSON.stringify(error.response, null, 2));
const errorMessage = error.response?.data?.error || error.message || t('errors.eventCreationFailed');
// If validation errors exist, show them
if (error.response?.data?.errors) {
const validationErrors = error.response.data.errors;
console.error('Validation errors:', validationErrors);
validationErrors.forEach((err: any) => {
toast.error(`${err.param}: ${err.msg}`);
});
} else {
toast.error(errorMessage);
}
},
});
const validateForm = (): boolean => {
const newErrors: Partial<Record<keyof FormData, string>> = {};
if (!formData.event_name) {
newErrors.event_name = t('validation.eventNameRequired');
}
if (!formData.event_date) {
newErrors.event_date = t('validation.eventDateRequired');
}
if (!formData.host_name) {
newErrors.host_name = t('validation.hostNameRequired');
}
if (!formData.host_email) {
newErrors.host_email = t('validation.hostEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) {
newErrors.host_email = t('validation.invalidEmailFormat');
}
if (!formData.admin_email) {
newErrors.admin_email = t('validation.adminEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
newErrors.admin_email = t('validation.invalidEmailFormat');
}
if (!formData.password) {
newErrors.password = t('validation.passwordRequired');
} else if (formData.password.length < 6) {
newErrors.password = t('validation.passwordMinLength');
} else if (/^\d{1,6}$/.test(formData.password)) {
// Prevent simple numeric passwords like "123456"
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
}
if (formData.password !== formData.confirm_password) {
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
}
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
newErrors.expires_in_days = t('validation.expirationRange');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
const payload = {
event_type: formData.event_type,
event_name: formData.event_name,
event_date: formData.event_date,
host_name: formData.host_name,
host_email: formData.host_email,
admin_email: formData.admin_email,
password: formData.password,
welcome_message: formData.welcome_message || '',
color_theme: JSON.stringify(formData.theme_config),
expiration_days: formData.expires_in_days,
allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id,
};
console.log('Submitting payload:', payload);
createMutation.mutate(payload);
};
const handleInputChange = (field: keyof FormData) => (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
setFormData({ ...formData, [field]: e.target.value });
setErrors({ ...errors, [field]: undefined });
};
const handleThemeChange = (newTheme: ThemeConfig) => {
setFormData(prev => ({
...prev,
theme_config: newTheme
}));
};
const handlePresetChange = (presetName: string) => {
const preset = GALLERY_THEME_PRESETS[presetName];
if (preset) {
setFormData(prev => ({
...prev,
theme_preset: presetName,
theme_config: preset.config
}));
}
};
return (
<div className="max-w-4xl mx-auto">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowLeft className="w-4 h-4" />}
onClick={() => navigate('/admin/events')}
>
{t('common.back')}
</Button>
<h1 className="text-2xl font-bold text-neutral-900">{t('events.create')}</h1>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Event Details */}
<Card>
<div className="p-6 space-y-6">
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
<Calendar className="w-5 h-5" />
{t('events.eventDetails')}
</h2>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.eventType')}
</label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{EVENT_TYPES.map((type) => (
<button
key={type.value}
type="button"
onClick={() => setFormData({ ...formData, event_type: type.value })}
className={`p-4 rounded-lg border-2 transition-all ${
formData.event_type === type.value
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="text-2xl mb-1">{type.emoji}</div>
<div className="text-sm font-medium">{t(type.labelKey)}</div>
</button>
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={t('events.eventName')}
placeholder={t('events.eventNamePlaceholder')}
value={formData.event_name}
onChange={handleInputChange('event_name')}
error={errors.event_name}
leftIcon={<Calendar className="w-5 h-5" />}
/>
<Input
type="date"
label={t('events.eventDate')}
value={formData.event_date}
onChange={handleInputChange('event_date')}
error={errors.event_date}
leftIcon={<Calendar className="w-5 h-5" />}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.welcomeMessage')}
</label>
<WelcomeMessageEditor
value={formData.welcome_message}
onChange={(value) => setFormData(prev => ({ ...prev, welcome_message: value }))}
placeholder={t('events.welcomeMessagePlaceholder')}
rows={4}
/>
</div>
</div>
</Card>
{/* Theme Selection */}
<Card>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
<Palette className="w-5 h-5" />
{t('events.themeAndStyle')}
</h2>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowThemeCustomizer(!showThemeCustomizer)}
leftIcon={showThemeCustomizer ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
>
{showThemeCustomizer ? t('common.hide') : t('common.customize')}
</Button>
</div>
{/* Quick Theme Preview */}
{!showThemeCustomizer && (
<div className="p-4 rounded-lg border border-neutral-200"
style={{
backgroundColor: formData.theme_config.backgroundColor,
color: formData.theme_config.textColor
}}
>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold" style={{ fontFamily: formData.theme_config.fontFamily }}>
{GALLERY_THEME_PRESETS[formData.theme_preset]?.name || 'Custom Theme'}
</h3>
<div className="flex gap-2">
<div
className="w-6 h-6 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: formData.theme_config.primaryColor }}
/>
<div
className="w-6 h-6 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: formData.theme_config.accentColor }}
/>
</div>
</div>
<p className="text-sm opacity-80">
Gallery Layout: <span className="font-medium capitalize">{formData.theme_config.galleryLayout || 'grid'}</span>
</p>
</div>
)}
{/* Theme Customizer */}
{showThemeCustomizer && (
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Theme Customizer */}
<ThemeCustomizerEnhanced
value={formData.theme_config}
onChange={handleThemeChange}
presetName={formData.theme_preset}
onPresetChange={handlePresetChange}
isPreviewMode={true}
showGalleryLayouts={true}
hideActions={true}
/>
{/* Gallery Preview */}
<div className="lg:sticky lg:top-4 lg:h-fit">
<GalleryPreview
theme={formData.theme_config}
className="shadow-lg"
/>
</div>
</div>
</div>
)}
</div>
</Card>
{/* Access & Security */}
<Card>
<div className="p-6 space-y-6">
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
<Lock className="w-5 h-5" />
{t('events.accessAndSecurity')}
</h2>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={t('events.hostName')}
placeholder={t('events.hostNamePlaceholder')}
value={formData.host_name}
onChange={handleInputChange('host_name')}
error={errors.host_name}
leftIcon={<Calendar className="w-5 h-5" />}
/>
<Input
type="email"
label={t('events.hostEmail')}
placeholder={t('events.hostEmailPlaceholder')}
value={formData.host_email}
onChange={handleInputChange('host_email')}
error={errors.host_email}
leftIcon={<Mail className="w-5 h-5" />}
/>
</div>
<Input
type="email"
label={t('events.adminEmail')}
placeholder={t('events.adminEmailPlaceholder')}
value={formData.admin_email}
onChange={handleInputChange('admin_email')}
error={errors.admin_email}
leftIcon={<Mail className="w-5 h-5" />}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.galleryPassword')}
placeholder={t('events.passwordPlaceholder')}
value={formData.password}
onChange={handleInputChange('password')}
error={errors.password}
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
leftIcon={<Lock className="w-5 h-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/>
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.confirmPassword')}
placeholder={t('events.confirmPasswordPlaceholder')}
value={formData.confirm_password}
onChange={handleInputChange('confirm_password')}
error={errors.confirm_password}
leftIcon={<Lock className="w-5 h-5" />}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.galleryExpiration')}
</label>
<div className="flex items-center gap-2">
<div className="w-32">
<Input
type="number"
value={formData.expires_in_days}
onChange={handleInputChange('expires_in_days')}
error={errors.expires_in_days}
min={1}
max={365}
leftIcon={<Clock className="w-5 h-5" />}
/>
</div>
<span className="text-sm text-neutral-600">{t('events.daysAfterEvent')}</span>
</div>
{formData.event_date && (
<p className="mt-2 text-sm text-neutral-500">
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days), 'PPP', { locale: i18n.language === 'de' ? de : enUS })}
</p>
)}
</div>
{/* User Upload Settings */}
<div className="pt-4 border-t border-neutral-200">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.allow_user_uploads}
onChange={(e) => setFormData({ ...formData, allow_user_uploads: e.target.checked })}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('events.allowUserUploads')}
</span>
<p className="text-xs text-neutral-500 mt-0.5">
{t('events.allowUserUploadsDescription')}
</p>
</div>
</label>
{formData.allow_user_uploads && categories && categories.length > 0 && (
<div className="mt-4 ml-7">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.uploadCategory')}
</label>
<select
value={formData.upload_category_id || ''}
onChange={(e) => setFormData({
...formData,
upload_category_id: e.target.value ? Number(e.target.value) : null
})}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">{t('events.selectCategory')}</option>
{categories.map(category => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
<p className="mt-1 text-xs text-neutral-500">
{t('events.uploadCategoryHelp')}
</p>
</div>
)}
</div>
</div>
</Card>
{/* Form Actions */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate('/admin/events')}
>
{t('common.cancel')}
</Button>
<Button
type="submit"
variant="primary"
isLoading={createMutation.isPending}
disabled={createMutation.isPending}
>
{t('events.createEvent')}
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,640 @@
import React, { useState } from 'react';
import {
Mail,
Save,
Send,
Server,
Lock,
User,
AlertCircle,
CheckCircle,
Eye,
EyeOff,
} from 'lucide-react';
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';
import { useTranslation } from 'react-i18next';
const defaultTemplateKeys = [
{
key: 'gallery_created',
name: 'Gallery Created',
subject: 'Your {{event_name}} photos are ready!',
body: `Hi there!
Your photo gallery for {{event_name}} is now ready to view.
Event: {{event_name}}
Date: {{event_date}}
Password: {{password}}
You can access your photos here: {{gallery_link}}
Your gallery will be available until {{expiration_date}}. Make sure to download your photos before they expire!
{{#if welcome_message}}
Personal message from your host:
{{welcome_message}}
{{/if}}
Best regards,
The Photo Sharing Team`,
variables: ['event_name', 'event_date', 'password', 'gallery_link', 'expiration_date', 'welcome_message']
},
{
key: 'expiration_warning',
name: 'Expiration Warning',
subject: 'Your {{event_name}} photos expire in {{days_remaining}} days!',
body: `Important: Your photo gallery is expiring soon!
Your photos from {{event_name}} will no longer be available after {{expiration_date}}.
You have {{days_remaining}} days remaining to download your photos.
Access your gallery here: {{gallery_link}}
Don't forget to download all your favorite memories before they're gone!
Best regards,
The Photo Sharing Team`,
variables: ['event_name', 'days_remaining', 'expiration_date', 'gallery_link']
},
{
key: 'gallery_expired',
name: 'Gallery Expired',
subject: 'Your {{event_name}} photo gallery has expired',
body: `Your photo gallery for {{event_name}} has expired and is no longer accessible.
The photos have been archived for safekeeping. If you need access to them, please contact the event administrator at {{admin_email}}.
Thank you for using our photo sharing service!
Best regards,
The Photo Sharing Team`,
variables: ['event_name', 'admin_email']
},
{
key: 'archive_complete',
name: 'Archive Complete (Admin)',
}
];
export const EmailConfigPage: React.FC = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'smtp' | 'templates'>('smtp');
const [selectedTemplateKey, setSelectedTemplateKey] = useState<string>('gallery_created');
const [editedTemplate, setEditedTemplate] = useState<Partial<EmailTemplate>>({});
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
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
const [smtpConfig, setSmtpConfig] = useState<EmailConfig>({
smtp_host: '',
smtp_port: 587,
smtp_secure: false,
smtp_user: '',
smtp_pass: '',
from_email: '',
from_name: 'Photo Sharing'
});
// Fetch SMTP config
const { isLoading: configLoading } = useQuery({
queryKey: ['email-config'],
queryFn: () => emailService.getConfig(),
});
// Fetch email templates
const { data: templates = [], isLoading: templatesLoading } = useQuery({
queryKey: ['email-templates'],
queryFn: () => emailService.getTemplates()
});
// Fetch selected template details
const { data: selectedTemplate } = useQuery({
queryKey: ['email-template', selectedTemplateKey],
queryFn: () => emailService.getTemplate(selectedTemplateKey),
enabled: !!selectedTemplateKey && activeTab === 'templates',
});
// Update local state when data is fetched
React.useEffect(() => {
const fetchConfig = async () => {
try {
const config = await emailService.getConfig();
setSmtpConfig(config);
} catch (error) {
// Config might not exist yet
}
};
fetchConfig();
}, []);
React.useEffect(() => {
if (selectedTemplate) {
setEditedTemplate(selectedTemplate);
}
}, [selectedTemplate]);
// Mutations
const saveConfigMutation = useMutation({
mutationFn: (config: EmailConfig) => emailService.updateConfig(config),
onSuccess: () => {
toast.success(t('toast.emailConfigSaved'));
queryClient.invalidateQueries({ queryKey: ['email-config'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const testEmailMutation = useMutation({
mutationFn: (email: string) => emailService.testEmail(email),
onSuccess: () => {
toast.success(t('email.testEmailSuccess'));
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const saveTemplateMutation = useMutation({
mutationFn: ({ key, template }: { key: string; template: Partial<EmailTemplate> }) =>
emailService.updateTemplate(key, template),
onSuccess: () => {
toast.success(t('toast.saveSuccess'));
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
queryClient.invalidateQueries({ queryKey: ['email-template', selectedTemplateKey] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const handleSaveSmtp = () => {
// Validate SMTP config
if (!smtpConfig.smtp_host || !smtpConfig.smtp_port || !smtpConfig.from_email) {
toast.error(t('errors.requiredFields'));
return;
}
saveConfigMutation.mutate(smtpConfig);
};
const handleTestEmail = () => {
if (!testEmail) {
toast.error(t('errors.enterTestEmail'));
return;
}
testEmailMutation.mutate(testEmail);
};
const handleSaveTemplate = () => {
if (selectedTemplateKey && editedTemplate) {
const templateData: Partial<EmailTemplate> = {};
// Include both language versions
if (editedTemplate.subject_en !== undefined) templateData.subject_en = editedTemplate.subject_en;
if (editedTemplate.subject_de !== undefined) templateData.subject_de = editedTemplate.subject_de;
if (editedTemplate.body_html_en !== undefined) templateData.body_html_en = editedTemplate.body_html_en;
if (editedTemplate.body_html_de !== undefined) templateData.body_html_de = editedTemplate.body_html_de;
if (editedTemplate.body_text_en !== undefined) templateData.body_text_en = editedTemplate.body_text_en;
if (editedTemplate.body_text_de !== undefined) templateData.body_text_de = editedTemplate.body_text_de;
saveTemplateMutation.mutate({
key: selectedTemplateKey,
template: templateData
});
}
};
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: 'admin@example.com',
host_email: 'host@example.com'
};
try {
const preview = await emailService.previewTemplate(selectedTemplateKey, sampleData, editingLang);
setPreviewData({
subject: preview.subject,
htmlContent: preview.body_html,
textContent: preview.body_text
});
setShowPreview(true);
} catch (error) {
toast.error(t('toast.saveError'));
}
};
const renderVariableHelp = () => {
const variables = editedTemplate.variables || [];
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-sm font-semibold text-blue-900 mb-2">{t('email.templateVariables')}</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
{variables.map(variable => (
<code key={variable} className="text-blue-700 bg-blue-100 px-2 py-1 rounded">
{`{{${variable}}}`}
</code>
))}
</div>
<p className="text-xs text-blue-700 mt-2">
{t('email.variableHelp')}
</p>
</div>
);
};
if (configLoading || templatesLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('email.loadingSettings')} />
</div>
);
}
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900">{t('email.title')}</h1>
<p className="text-neutral-600 mt-1">{t('email.subtitle')}</p>
</div>
{/* Tab Navigation */}
<div className="border-b border-neutral-200 mb-6">
<nav className="-mb-px flex gap-6">
<button
onClick={() => setActiveTab('smtp')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'smtp'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{t('email.smtpSettings')}
</button>
<button
onClick={() => setActiveTab('templates')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'templates'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{t('email.emailTemplates')}
</button>
</nav>
</div>
{/* SMTP Settings Tab */}
{activeTab === 'smtp' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('email.smtpConfiguration')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('email.smtpHost')} <span className="text-red-500">*</span>
</label>
<Input
type="text"
value={smtpConfig.smtp_host}
onChange={(e) => setSmtpConfig(prev => ({ ...prev, smtp_host: e.target.value }))}
placeholder="smtp.gmail.com"
leftIcon={<Server className="w-5 h-5 text-neutral-400" />}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('email.port')} <span className="text-red-500">*</span>
</label>
<Input
type="number"
value={smtpConfig.smtp_port}
onChange={(e) => setSmtpConfig(prev => ({ ...prev, smtp_port: parseInt(e.target.value) || 587 }))}
placeholder="587"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('email.security')}
</label>
<select
value={smtpConfig.smtp_secure ? 'ssl' : 'tls'}
onChange={(e) => setSmtpConfig(prev => ({ ...prev, smtp_secure: e.target.value === 'ssl' }))}
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="tls">TLS</option>
<option value="ssl">SSL</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('email.username')}
</label>
<Input
type="text"
value={smtpConfig.smtp_user}
onChange={(e) => setSmtpConfig(prev => ({ ...prev, smtp_user: e.target.value }))}
placeholder="your-email@gmail.com"
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('email.password')}
</label>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
value={smtpConfig.smtp_pass}
onChange={(e) => setSmtpConfig(prev => ({ ...prev, smtp_pass: e.target.value }))}
placeholder={t('email.enterPassword')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('email.fromEmail')} <span className="text-red-500">*</span>
</label>
<Input
type="email"
value={smtpConfig.from_email}
onChange={(e) => setSmtpConfig(prev => ({ ...prev, from_email: e.target.value }))}
placeholder="noreply@yourdomain.com"
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('email.fromName')}
</label>
<Input
type="text"
value={smtpConfig.from_name}
onChange={(e) => setSmtpConfig(prev => ({ ...prev, from_name: e.target.value }))}
placeholder="Photo Sharing"
/>
</div>
<Button
variant="primary"
onClick={handleSaveSmtp}
isLoading={saveConfigMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
className="w-full"
>
{t('email.saveSmtpSettings')}
</Button>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('email.testEmailSection')}</h2>
<div className="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0" />
<div className="text-sm text-amber-800">
<p className="font-medium">{t('email.beforeTesting')}</p>
<ul className="list-disc list-inside mt-1">
<li>{t('email.saveSmtpFirst')}</li>
<li>{t('email.ensureFirewall')}</li>
<li>{t('email.gmailAppPassword')}</li>
</ul>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('email.testEmailAddressLabel')}
</label>
<Input
type="email"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder="test@example.com"
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
/>
</div>
<Button
variant="outline"
onClick={handleTestEmail}
isLoading={testEmailMutation.isPending}
leftIcon={<Send className="w-5 h-5" />}
className="w-full"
>
{t('email.sendTestEmailButton')}
</Button>
</div>
<div className="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0" />
<div className="text-sm text-green-800">
<p className="font-medium">{t('email.commonSmtpSettings')}</p>
<ul className="mt-2 space-y-1">
<li><strong>Gmail:</strong> smtp.gmail.com:587 (TLS)</li>
<li><strong>Outlook:</strong> smtp-mail.outlook.com:587 (TLS)</li>
<li><strong>SendGrid:</strong> smtp.sendgrid.net:587 (TLS)</li>
</ul>
</div>
</div>
</div>
</Card>
</div>
)}
{/* Email Templates Tab */}
{activeTab === 'templates' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card padding="sm">
<h3 className="text-lg font-semibold text-neutral-900 mb-4">{t('email.templates')}</h3>
<div className="space-y-2">
{templates.map(template => {
const templateInfo = defaultTemplateKeys.find(t => t.key === template.template_key);
return (
<button
key={template.template_key}
onClick={() => {
setSelectedTemplateKey(template.template_key);
setEditedTemplate(template);
}}
className={`w-full text-left p-3 rounded-lg transition-colors ${
selectedTemplateKey === template.template_key
? 'bg-primary-50 border-2 border-primary-600'
: 'bg-neutral-50 border-2 border-transparent hover:bg-neutral-100'
}`}
>
<p className="font-medium text-neutral-900">
{templateInfo?.name || template.template_key}
</p>
<p className="text-sm text-neutral-500 mt-1 truncate">
{template.subject_en || template.subject}
</p>
</button>
);
})}
</div>
</Card>
<div className="lg:col-span-2">
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900">{t('email.editTemplate')}</h3>
<div className="flex gap-2">
<div className="flex gap-1 mr-4">
<button
onClick={() => setEditingLang('en')}
className={`px-3 py-1 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'en'
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
🇬🇧 English
</button>
<button
onClick={() => setEditingLang('de')}
className={`px-3 py-1 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'de'
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
🇩🇪 Deutsch
</button>
</div>
<Button
variant="outline"
size="sm"
onClick={handlePreviewTemplate}
leftIcon={<Eye className="w-4 h-4" />}
>
{t('email.preview')}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSaveTemplate}
isLoading={saveTemplateMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('email.saveChanges')}
</Button>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('email.templateName')}
</label>
<Input
type="text"
value={defaultTemplateKeys.find(t => t.key === selectedTemplateKey)?.name || selectedTemplateKey}
disabled
className="bg-neutral-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('email.subjectLine')} ({editingLang === 'en' ? 'English' : 'German'})
</label>
<Input
type="text"
value={
editingLang === 'en'
? (editedTemplate.subject_en || editedTemplate.subject || '')
: (editedTemplate.subject_de || '')
}
onChange={(e) => setEditedTemplate(prev => ({
...prev,
[editingLang === 'en' ? 'subject_en' : 'subject_de']: e.target.value
}))}
placeholder="Email subject"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('email.emailBody')} ({editingLang === 'en' ? 'English' : 'German'})
</label>
<textarea
value={
editingLang === 'en'
? (editedTemplate.body_html_en || editedTemplate.body_html || '')
: (editedTemplate.body_html_de || '')
}
onChange={(e) => setEditedTemplate(prev => ({
...prev,
[editingLang === 'en' ? 'body_html_en' : 'body_html_de']: e.target.value
}))}
rows={15}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono text-sm"
/>
</div>
{renderVariableHelp()}
</div>
</Card>
</div>
</div>
)}
{/* Email Preview Modal */}
<EmailPreviewModal
isOpen={showPreview}
onClose={() => setShowPreview(false)}
subject={previewData.subject}
htmlContent={previewData.htmlContent}
textContent={previewData.textContent}
/>
</div>
);
};
@@ -0,0 +1,936 @@
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,
Mail
} 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 { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal } 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 [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
// 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') {
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'));
}
};
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>
{/* 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 space-y-2">
<Button
variant="outline"
size="sm"
leftIcon={<Key className="w-4 h-4" />}
onClick={() => setShowPasswordReset(true)}
className="w-full justify-center"
>
{t('events.resetGalleryPassword')}
</Button>
<Button
variant="outline"
size="sm"
leftIcon={<Mail className="w-4 h-4" />}
onClick={async () => {
try {
await eventsService.resendCreationEmail(event.id);
toast.success(t('events.creationEmailResent'));
} catch (error) {
toast.error(t('events.failedToResendEmail'));
}
}}
className="w-full justify-center"
>
{t('events.resendCreationEmail')}
</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>
{/* Theme & Style */}
{isEditing && !event.is_archived && (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('branding.themeAndStyle')}</h2>
<ThemeCustomizerEnhanced
value={currentTheme || GALLERY_THEME_PRESETS.default.config}
onChange={(theme) => {
setCurrentTheme(theme);
setEditForm(prev => ({ ...prev, color_theme: JSON.stringify(theme) }));
}}
presetName={currentPresetName}
onPresetChange={(presetName) => {
setCurrentPresetName(presetName);
if (presetName !== 'custom') {
const preset = GALLERY_THEME_PRESETS[presetName];
if (preset) {
setCurrentTheme(preset.config);
setEditForm(prev => ({ ...prev, color_theme: presetName }));
}
}
}}
isPreviewMode={false}
showGalleryLayouts={true}
/>
</Card>
)}
{/* Theme Display (when not editing) */}
{!isEditing && !event.is_archived && (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.galleryTheme')}</h2>
<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 Modal */}
<PhotoUploadModal
isOpen={showPhotoUpload}
onClose={() => setShowPhotoUpload(false)}
eventId={parseInt(id!)}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
queryClient.invalidateQueries({ queryKey: ['admin-event-photos', id] });
toast.success(t('toast.uploadSuccess'));
refetchPhotos();
}}
/>
{/* 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 */}
<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)}
/>
)}
</div>
);
};
EventDetailsPage.displayName = 'EventDetailsPage';
+561
View File
@@ -0,0 +1,561 @@
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Plus,
Search,
Archive,
AlertTriangle,
MoreVertical,
ExternalLink,
Edit,
Download,
Trash2,
Calendar,
Users,
Image,
Activity
} from 'lucide-react';
import { parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
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';
import { useTranslation } from 'react-i18next';
export const EventsListPage: React.FC = () => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const [searchTerm, setSearchTerm] = useState('');
const [selectedEvents, setSelectedEvents] = useState<number[]>([]);
// const [showFilters, setShowFilters] = useState(false);
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
// Get filter from URL
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | null;
const isExpiringFilter = searchParams.get('filter') === 'expiring';
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.dropdown-container')) {
setActiveDropdown(null);
setDropdownPosition(null);
}
};
if (activeDropdown !== null) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [activeDropdown]);
// Update dropdown position on scroll/resize
useEffect(() => {
const handleScrollOrResize = () => {
if (activeDropdown !== null) {
setActiveDropdown(null);
setDropdownPosition(null);
}
};
window.addEventListener('scroll', handleScrollOrResize, true);
window.addEventListener('resize', handleScrollOrResize);
return () => {
window.removeEventListener('scroll', handleScrollOrResize, true);
window.removeEventListener('resize', handleScrollOrResize);
};
}, [activeDropdown]);
// Fetch events
const { data, isLoading, error } = useQuery({
queryKey: ['admin-events', statusFilter],
queryFn: () => eventsService.getEvents(1, 100, (statusFilter === 'archived' || statusFilter === 'active') ? statusFilter : undefined),
});
// Archive mutation
const archiveMutation = useMutation({
mutationFn: eventsService.archiveEvent,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
toast.success(t('toast.eventArchived'));
},
onError: () => {
toast.error(t('toast.saveError'));
},
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: eventsService.deleteEvent,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
toast.success(t('toast.deleteSuccess'));
},
onError: () => {
toast.error(t('toast.deleteError'));
},
});
// 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(t('events.bulkArchiveSuccess', { count: data.results.successful.length }));
} else {
toast.warning(t('events.bulkArchivePartial', { success: data.results.successful.length, failed: data.results.failed.length }));
}
},
onError: () => {
toast.error(t('toast.saveError'));
},
});
// Filter and search events
const filteredEvents = useMemo(() => {
if (!data?.events) return [];
let events = [...data.events];
// Apply status filter
if (statusFilter === 'active') {
events = events.filter(e => e.is_active && !e.is_archived);
} else if (isExpiringFilter) {
events = events.filter(e => {
if (!e.is_active || e.is_archived) return false;
const days = e.expires_at ? differenceInDays(parseISO(e.expires_at), new Date()) : 0;
return days <= 7 && days > 0;
});
} else if (statusFilter === 'archived') {
events = events.filter(e => e.is_archived);
}
// Apply search
if (searchTerm) {
const term = searchTerm.toLowerCase();
events = events.filter(e =>
e.event_name.toLowerCase().includes(term) ||
e.event_type.toLowerCase().includes(term) ||
e.host_email.toLowerCase().includes(term)
);
}
// Sort by creation date (newest first)
events.sort((a, b) => {
const dateA = a.created_at ? new Date(a.created_at).getTime() : 0;
const dateB = b.created_at ? new Date(b.created_at).getTime() : 0;
return dateB - dateA;
});
return events;
}, [data?.events, statusFilter, searchTerm]);
const handleSelectAll = () => {
if (selectedEvents.length === filteredEvents.length) {
setSelectedEvents([]);
} else {
setSelectedEvents(filteredEvents.map(e => e.id));
}
};
const handleSelectEvent = (id: number) => {
setSelectedEvents(prev =>
prev.includes(id)
? prev.filter(i => i !== id)
: [...prev, id]
);
};
const getEventStatus = (event: Event) => {
if (event.is_archived) return { label: t('events.archived'), color: 'text-neutral-500 bg-neutral-100' };
if (!event.is_active) return { label: t('events.inactive'), color: 'text-red-600 bg-red-100' };
const days = event.expires_at ? differenceInDays(parseISO(event.expires_at), new Date()) : 0;
if (days <= 0) return { label: t('events.expired'), color: 'text-red-600 bg-red-100' };
if (days <= 7) return { label: t('events.daysLeft', { count: days }), color: 'text-orange-600 bg-orange-100' };
return { label: t('events.active'), color: 'text-green-600 bg-green-100' };
};
if (isLoading) {
return (
<div>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-neutral-900">{t('events.title')}</h1>
<p className="text-neutral-600 mt-1">{t('events.subtitle')}</p>
</div>
</div>
<SkeletonTable rows={5} />
</div>
);
}
if (error) {
return (
<div className="text-center py-12">
<p className="text-red-600">{t('events.failedToLoadEvents')}</p>
<Button onClick={() => window.location.reload()} className="mt-4">
{t('events.tryAgain')}
</Button>
</div>
);
}
return (
<ErrorBoundary>
<div>
{/* Page Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-neutral-900">{t('events.title')}</h1>
<p className="text-neutral-600 mt-1">{t('events.subtitle')}</p>
</div>
<Button
variant="primary"
leftIcon={<Plus className="w-5 h-5" />}
onClick={() => navigate('/admin/events/new')}
>
{t('events.createEvent')}
</Button>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{t('events.stats.totalEvents')}</p>
<p className="text-2xl font-bold text-neutral-900">{data?.events.length || 0}</p>
</div>
<Calendar className="w-8 h-8 text-primary-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{t('events.stats.activeEvents')}</p>
<p className="text-2xl font-bold text-neutral-900">
{data?.events.filter(e => e.is_active && !e.is_archived).length || 0}
</p>
</div>
<Activity className="w-8 h-8 text-green-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{t('events.stats.totalPhotos')}</p>
<p className="text-2xl font-bold text-neutral-900">
{data?.events.reduce((sum, e) => sum + (e.photo_count || 0), 0) || 0}
</p>
</div>
<Image className="w-8 h-8 text-blue-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{t('events.stats.expiringEvents')}</p>
<p className="text-2xl font-bold text-neutral-900">
{data?.events.filter(e => {
if (!e.is_active || e.is_archived) return false;
const days = e.expires_at ? differenceInDays(parseISO(e.expires_at), new Date()) : 0;
return days <= 7 && days > 0;
}).length || 0}
</p>
</div>
<AlertTriangle className="w-8 h-8 text-orange-600" />
</div>
</Card>
</div>
{/* Filters and Search */}
<Card padding="sm" className="mb-6">
<div className="flex flex-col lg:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<Input
type="text"
placeholder={t('events.searchEventsPlaceholder')}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Filter Buttons */}
<div className="flex gap-2">
<Button
variant={!statusFilter ? 'primary' : 'outline'}
size="md"
onClick={() => {
searchParams.delete('filter');
setSearchParams(searchParams);
}}
>
{t('events.all')} ({data?.events.length || 0})
</Button>
<Button
variant={statusFilter === 'active' ? 'primary' : 'outline'}
size="md"
onClick={() => setSearchParams({ filter: 'active' })}
>
{t('events.active')}
</Button>
<Button
variant={isExpiringFilter ? 'primary' : 'outline'}
size="md"
onClick={() => setSearchParams({ filter: 'expiring' })}
leftIcon={<AlertTriangle className="w-4 h-4" />}
>
{t('events.expiring')}
</Button>
<Button
variant={statusFilter === 'archived' ? 'primary' : 'outline'}
size="md"
onClick={() => setSearchParams({ filter: 'archived' })}
leftIcon={<Archive className="w-4 h-4" />}
>
{t('events.archived')}
</Button>
</div>
</div>
{/* Bulk Actions */}
{selectedEvents.length > 0 && (
<div className="mt-4 p-3 bg-primary-50 rounded-lg flex items-center justify-between">
<span className="text-sm text-primary-900">
{t('events.eventsSelected', { count: selectedEvents.length })}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setSelectedEvents([])}>
{t('events.clear')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowBulkArchiveModal(true)}
>
{t('events.archiveSelected')}
</Button>
</div>
</div>
)}
</Card>
{/* Events Table */}
<Card className="overflow-visible">
<div className="overflow-x-auto overflow-y-visible">
<table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-6 py-3 text-left">
<input
type="checkbox"
checked={selectedEvents.length === filteredEvents.length && filteredEvents.length > 0}
onChange={handleSelectAll}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('events.event')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('events.type')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('events.date')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('events.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('events.expires')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('events.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-neutral-200">
{filteredEvents.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-neutral-500">
{t('events.noEventsFound')}
</td>
</tr>
) : (
filteredEvents.map((event) => {
const status = getEventStatus(event);
return (
<tr key={event.id} className="hover:bg-neutral-50">
<td className="px-6 py-4">
<input
type="checkbox"
checked={selectedEvents.includes(event.id)}
onChange={() => handleSelectEvent(event.id)}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
</td>
<td className="px-6 py-4">
<div>
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p>
<p className="text-xs text-neutral-500">{event.host_email}</p>
</div>
</td>
<td className="px-6 py-4 text-sm text-neutral-700">
{event.event_type}
</td>
<td className="px-6 py-4 text-sm text-neutral-700">
{event.event_date ? format(parseISO(event.event_date), 'MMM d, yyyy') : 'N/A'}
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${status.color}`}>
{status.label}
</span>
</td>
<td className="px-6 py-4 text-sm text-neutral-700">
{event.expires_at ? format(parseISO(event.expires_at), 'MMM d, yyyy') : 'N/A'}
</td>
<td className="px-6 py-4 text-right">
<div className="relative inline-block text-left dropdown-container">
<button
onClick={(e) => {
e.stopPropagation();
if (activeDropdown === event.id) {
setActiveDropdown(null);
setDropdownPosition(null);
} else {
const rect = e.currentTarget.getBoundingClientRect();
setActiveDropdown(event.id);
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.right - 224 + window.scrollX // 224px = 14rem (w-56)
});
}
}}
className="text-neutral-400 hover:text-neutral-600 p-1"
>
<MoreVertical className="w-5 h-5" />
</button>
{activeDropdown === event.id && dropdownPosition && (
<div
className="fixed z-50 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5"
style={{ top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px` }}
>
<div className="py-1">
<button
onClick={() => {
navigate(`/admin/events/${event.id}`);
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
<Edit className="w-4 h-4" />
{t('events.viewDetails')}
</button>
{event.share_link ? (
<a
href={event.share_link}
target="_blank"
rel="noopener noreferrer"
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
onClick={() => {
setActiveDropdown(null);
setDropdownPosition(null);
}}
>
<ExternalLink className="w-4 h-4" />
{t('events.viewGallery')}
</a>
) : null}
{!event.is_archived ? (
<button
onClick={() => {
archiveMutation.mutate(event.id);
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
<Archive className="w-4 h-4" />
{t('events.archiveEventAction')}
</button>
) : null}
{event.is_archived ? (
<button
onClick={() => {
toast.info(t('events.downloadArchiveSoon'));
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t('events.downloadArchiveAction')}
</button>
) : null}
<button
onClick={() => {
if (confirm(t('events.deleteEventConfirm'))) {
deleteMutation.mutate(event.id);
setActiveDropdown(null);
setDropdownPosition(null);
}
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
{t('events.deleteEvent')}
</button>
</div>
</div>
)}
</div>
</td>
</tr>
);
})
)}
</tbody>
</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>
);
};
EventsListPage.displayName = 'EventsListPage';
+759
View File
@@ -0,0 +1,759 @@
import React, { useState } from 'react';
import {
Save,
Database,
Globe,
Key,
AlertCircle,
Image,
Server,
CheckCircle,
Clock,
HardDrive,
Activity
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Input, Loading } from '../../components/common';
import { CategoryManager } from '../../components/admin/CategoryManager';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories'>('general');
const queryClient = useQueryClient();
const { t, i18n } = useTranslation();
// Fetch settings
const { data: settings, isLoading } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings(),
});
// Fetch storage info
const { data: storageInfo } = useQuery({
queryKey: ['admin-storage-info'],
queryFn: () => settingsService.getStorageInfo(),
enabled: activeTab === 'status'
});
// Fetch system status
const { data: systemStatus } = useQuery({
queryKey: ['system-status'],
queryFn: () => settingsService.getSystemStatus(),
enabled: activeTab === 'status',
refetchInterval: 30000 // Refresh every 30 seconds
});
// General settings state
const [generalSettings, setGeneralSettings] = useState({
site_url: '',
default_expiration_days: 30,
max_file_size_mb: 50,
allowed_file_types: 'jpg,jpeg,png,gif,webp',
enable_watermark: false,
enable_analytics: true,
enable_registration: false,
maintenance_mode: false,
default_language: 'en',
date_format: { format: 'DD/MM/YYYY', locale: 'en-GB' }
});
// Security settings state
const [securitySettings, setSecuritySettings] = useState({
require_password: true,
password_min_length: 8,
enable_2fa: false,
session_timeout_minutes: 60,
max_login_attempts: 5,
enable_recaptcha: false,
recaptcha_site_key: '',
recaptcha_secret_key: ''
});
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 || '',
default_expiration_days: settings.general_default_expiration_days || 30,
max_file_size_mb: settings.general_max_file_size_mb || 50,
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
enable_watermark: settings.general_enable_watermark || false,
enable_analytics: settings.general_enable_analytics || true,
enable_registration: settings.general_enable_registration || false,
maintenance_mode: settings.general_maintenance_mode || false,
default_language: settings.general_default_language || 'en',
date_format: settings.general_date_format || { format: 'DD/MM/YYYY', locale: 'en-GB' }
});
// Extract security settings
setSecuritySettings({
require_password: settings.security_require_password || true,
password_min_length: settings.security_password_min_length || 8,
enable_2fa: settings.security_enable_2fa || false,
session_timeout_minutes: settings.security_session_timeout_minutes || 60,
max_login_attempts: settings.security_max_login_attempts || 5,
enable_recaptcha: settings.security_enable_recaptcha || false,
recaptcha_site_key: settings.security_recaptcha_site_key || '',
recaptcha_secret_key: settings.security_recaptcha_secret_key || ''
});
}
}, [settings]);
// Save mutations
const saveGeneralMutation = useMutation({
mutationFn: async () => {
// Convert to the format expected by the API
const settingsData: Record<string, any> = {};
Object.entries(generalSettings).forEach(([key, value]) => {
settingsData[`general_${key}`] = value;
});
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const saveSecurityMutation = useMutation({
mutationFn: async () => {
// Convert to the format expected by the API
const settingsData: Record<string, any> = {};
Object.entries(securitySettings).forEach(([key, value]) => {
settingsData[`security_${key}`] = value;
});
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('settings.loadingSettings')} />
</div>
);
}
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900">{t('settings.title')}</h1>
<p className="text-neutral-600 mt-1">{t('settings.subtitle')}</p>
</div>
{/* Tab Navigation */}
<div className="border-b border-neutral-200 mb-6">
<nav className="-mb-px flex gap-6">
<button
onClick={() => setActiveTab('general')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'general'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{t('settings.general.title')}
</button>
<button
onClick={() => setActiveTab('status')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'status'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{t('settings.systemStatus.title')}
</button>
<button
onClick={() => setActiveTab('security')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'security'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{t('settings.security.title')}
</button>
<button
onClick={() => setActiveTab('categories')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'categories'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{t('settings.categories.title')}
</button>
</nav>
</div>
{/* General Settings Tab */}
{activeTab === 'general' && (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.siteConfiguration')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.siteUrl')}
</label>
<Input
type="url"
value={generalSettings.site_url}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, site_url: e.target.value }))}
placeholder="https://yourdomain.com"
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.siteUrlHelp')}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.defaultExpiration')}
</label>
<Input
type="number"
value={generalSettings.default_expiration_days}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, default_expiration_days: parseInt(e.target.value) || 30 }))}
min="1"
max="365"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.maxFileSize')}
</label>
<Input
type="number"
value={generalSettings.max_file_size_mb}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, max_file_size_mb: parseInt(e.target.value) || 50 }))}
min="1"
max="500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.allowedFileTypes')}
</label>
<Input
type="text"
value={generalSettings.allowed_file_types}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, allowed_file_types: e.target.value }))}
placeholder="jpg,jpeg,png,gif"
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.allowedFileTypesHelp')}
</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.featureToggles')}</h2>
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.enable_watermark}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_watermark: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableWatermark')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.enable_analytics}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_analytics: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableAnalytics')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.enable_registration}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_registration: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableRegistration')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.maintenance_mode}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, maintenance_mode: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.maintenanceMode')}</span>
</label>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.language')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('settings.general.language')}
</label>
<select
value={generalSettings.default_language}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, default_language: e.target.value }))}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.defaultLanguageHelp')}
</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.dateTimeFormat')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('settings.general.dateFormat')}
</label>
<select
value={generalSettings.date_format?.format || 'DD/MM/YYYY'}
onChange={(e) => {
const format = e.target.value;
const locale = format === 'MM/DD/YYYY' ? 'en-US' : 'en-GB';
setGeneralSettings(prev => ({
...prev,
date_format: { format, locale }
}));
}}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="DD/MM/YYYY">DD/MM/YYYY (European)</option>
<option value="MM/DD/YYYY">MM/DD/YYYY (US)</option>
<option value="YYYY-MM-DD">YYYY-MM-DD (ISO)</option>
<option value="DD.MM.YYYY">DD.MM.YYYY (German)</option>
</select>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.dateFormatHelp')}
</p>
</div>
</div>
<div className="mt-6">
<Button
variant="primary"
onClick={() => saveGeneralMutation.mutate()}
isLoading={saveGeneralMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
{t('settings.general.saveGeneralSettings')}
</Button>
</div>
</Card>
</div>
)}
{/* System Status Tab */}
{activeTab === 'status' && (
<div className="space-y-6">
{/* Storage Overview */}
{storageInfo && (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<HardDrive className="w-5 h-5" />
{t('settings.systemStatus.storageOverview')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.storage.totalUsed')}</p>
<p className="text-2xl font-bold text-neutral-900">
{settingsService.formatBytes(storageInfo.total_used)}
</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.storage.archiveStorage')}</p>
<p className="text-2xl font-bold text-neutral-900">
{settingsService.formatBytes(storageInfo.archive_storage)}
</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.storage.storageLimit')}</p>
<p className="text-2xl font-bold text-neutral-900">
{settingsService.formatBytes(storageInfo.storage_limit)}
</p>
</div>
</div>
<div className="mb-4">
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-600">{t('settings.storage.storageUsage')}</span>
<span className="font-medium">
{Math.round((storageInfo.total_used / storageInfo.storage_limit) * 100)}%
</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-3">
<div
className="bg-primary-600 h-3 rounded-full transition-all"
style={{
width: `${Math.min((storageInfo.total_used / storageInfo.storage_limit) * 100, 100)}%`
}}
/>
</div>
</div>
</Card>
)}
{/* System Information */}
{systemStatus && (
<>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Server className="w-5 h-5" />
{t('settings.systemStatus.systemInfo')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.systemStatus.platform')}</p>
<p className="font-semibold">{systemStatus.system.platform}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.systemStatus.nodeVersion')}</p>
<p className="font-semibold">{systemStatus.system.nodeVersion}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.systemStatus.uptime')}</p>
<p className="font-semibold">{Math.floor(systemStatus.system.uptime / 3600)}h {Math.floor((systemStatus.system.uptime % 3600) / 60)}m</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.systemStatus.cpuCores')}</p>
<p className="font-semibold">{systemStatus.system.cpu.cores}</p>
</div>
</div>
<div className="mt-4">
<h3 className="text-sm font-semibold text-neutral-900 mb-2">{t('settings.systemStatus.memoryUsage')}</h3>
<div className="mb-2">
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-600">{t('settings.systemStatus.memoryUsed')}</span>
<span className="font-medium">
{settingsService.formatBytes(systemStatus.system.memory.used)} / {settingsService.formatBytes(systemStatus.system.memory.total)}
</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{
width: `${Math.round((systemStatus.system.memory.used / systemStatus.system.memory.total) * 100)}%`
}}
/>
</div>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Database className="w-5 h-5" />
{t('settings.systemStatus.databaseInfo')}
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.events}</p>
<p className="text-xs text-neutral-600">{t('navigation.events')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.photos}</p>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.photos')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.admins}</p>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.admins')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.categories}</p>
<p className="text-xs text-neutral-600">{t('settings.categories.title')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{settingsService.formatBytes(systemStatus.database.size)}</p>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.dbSize')}</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Activity className="w-5 h-5" />
{t('settings.systemStatus.services')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-neutral-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-neutral-700">{t('settings.systemStatus.fileWatcher')}</p>
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.fileWatcherDesc')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-neutral-700">{t('settings.systemStatus.expirationChecker')}</p>
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.expirationCheckerDesc')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-neutral-700">{t('settings.systemStatus.emailProcessor')}</p>
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.emailProcessorDesc')}</p>
</div>
</div>
<div className="mt-4 p-4 bg-blue-50 rounded-lg">
<h3 className="text-sm font-semibold text-blue-900 mb-2">{t('settings.systemStatus.emailQueue')}</h3>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-blue-700">{t('settings.systemStatus.pending')}:</span>
<span className="ml-2 font-semibold text-blue-900">
{systemStatus.emailQueue.pending}
{systemStatus.emailQueue.stuck > 0 && (
<span className="text-orange-600 text-xs ml-1">
({systemStatus.emailQueue.stuck} stuck)
</span>
)}
</span>
</div>
<div>
<span className="text-green-700">{t('settings.systemStatus.sent')}:</span>
<span className="ml-2 font-semibold text-green-900">{systemStatus.emailQueue.sent}</span>
</div>
<div>
<span className="text-red-700">{t('settings.systemStatus.failed')}:</span>
<span className="ml-2 font-semibold text-red-900">{systemStatus.emailQueue.failed}</span>
</div>
</div>
{systemStatus.emailQueue.stuck > 0 && (
<div className="mt-3 p-3 bg-orange-50 rounded-md">
<p className="text-xs text-orange-800">
<span className="font-semibold"> {systemStatus.emailQueue.stuck} email(s) stuck:</span> These emails have exceeded retry limits and won't be processed automatically.
Only {systemStatus.emailQueue.processable} of {systemStatus.emailQueue.pending} pending emails will be processed.
</p>
</div>
)}
</div>
</Card>
</>
)}
{/* Last update time */}
{systemStatus && (
<div className="text-xs text-neutral-500 text-right flex items-center justify-end gap-1">
<Clock className="w-3 h-3" />
{t('settings.systemStatus.lastUpdate')}: {new Date(systemStatus.timestamp).toLocaleString()}
</div>
)}
</div>
)}
{/* Security Tab */}
{activeTab === 'security' && (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.passwordSettings')}</h2>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
checked={securitySettings.require_password}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, require_password: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.security.requirePassword')}</span>
</label>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.minPasswordLength')}
</label>
<Input
type="number"
value={securitySettings.password_min_length}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, password_min_length: parseInt(e.target.value) || 8 }))}
min="4"
max="32"
/>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.sessionAuth')}</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.sessionTimeout')}
</label>
<Input
type="number"
value={securitySettings.session_timeout_minutes}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, session_timeout_minutes: parseInt(e.target.value) || 60 }))}
min="5"
max="1440"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.maxLoginAttempts')}
</label>
<Input
type="number"
value={securitySettings.max_login_attempts}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, max_login_attempts: parseInt(e.target.value) || 5 }))}
min="3"
max="10"
/>
</div>
</div>
<label className="flex items-center">
<input
type="checkbox"
checked={securitySettings.enable_2fa}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_2fa: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.security.enable2FA')}</span>
</label>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.recaptchaSettings')}</h2>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
checked={securitySettings.enable_recaptcha}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_recaptcha: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.security.enableRecaptcha')}</span>
</label>
{securitySettings.enable_recaptcha && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.siteKey')}
</label>
<Input
type="text"
value={securitySettings.recaptcha_site_key}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, recaptcha_site_key: e.target.value }))}
placeholder={t('settings.security.siteKey')}
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.secretKey')}
</label>
<Input
type="password"
value={securitySettings.recaptcha_secret_key}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, recaptcha_secret_key: e.target.value }))}
placeholder={t('settings.security.secretKey')}
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
</div>
</>
)}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800">
<p>{t('settings.security.recaptchaHelp')} <a href="https://www.google.com/recaptcha/admin" target="_blank" rel="noopener noreferrer" className="underline">Google reCAPTCHA Admin</a></p>
</div>
</div>
</div>
</div>
<div className="mt-6">
<Button
variant="primary"
onClick={() => saveSecurityMutation.mutate()}
isLoading={saveSecurityMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
{t('settings.security.saveSecuritySettings')}
</Button>
</div>
</Card>
</div>
)}
{/* Categories Tab */}
{activeTab === 'categories' && (
<div className="space-y-6">
<Card padding="md">
<CategoryManager />
</Card>
<Card padding="md">
<div className="flex items-start gap-3">
<Image className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-blue-900">{t('settings.categories.about')}</h3>
<p className="text-sm text-blue-700 mt-1">
{t('settings.categories.aboutText')}
</p>
</div>
</div>
</Card>
</div>
)}
</div>
);
};
+11
View File
@@ -0,0 +1,11 @@
export { AdminLoginPage } from './AdminLoginPage';
export { AdminDashboard } from './AdminDashboard';
export { EventsListPage } from './EventsListPage';
export { CreateEventPageEnhanced } from './CreateEventPageEnhanced';
export { EventDetailsPage } from './EventDetailsPage';
export { EmailConfigPage } from './EmailConfigPage';
export { ArchivesPage } from './ArchivesPage';
export { AnalyticsPage } from './AnalyticsPage';
export { BrandingPage } from './BrandingPage';
export { SettingsPage } from './SettingsPage';
export { CMSPage } from './CMSPage';
+143
View File
@@ -0,0 +1,143 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useTheme } from '../../contexts/ThemeContext';
import { GalleryLayout, PhotoFilterBar } from '../../components/gallery';
import { Card } from '../../components/common';
import { Camera } from 'lucide-react';
// Mock photo data for preview
const generateMockPhotos = (count: number) => {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
filename: `photo-${i + 1}.jpg`,
url: '',
thumbnail_url: '',
type: i % 3 === 0 ? 'collage' : 'individual',
category_id: (i % 4) + 1,
category_name: ['Ceremony', 'Reception', 'Portraits', 'Party'][i % 4],
category_slug: ['ceremony', 'reception', 'portraits', 'party'][i % 4],
size: Math.floor(Math.random() * 5000000) + 1000000,
uploaded_at: new Date().toISOString(),
}));
};
const mockCategories = [
{ id: 1, name: 'Ceremony', slug: 'ceremony', is_global: true },
{ id: 2, name: 'Reception', slug: 'reception', is_global: true },
{ id: 3, name: 'Portraits', slug: 'portraits', is_global: true },
{ id: 4, name: 'Party', slug: 'party', is_global: true },
];
export const PreviewPage: React.FC = () => {
const { setTheme } = useTheme();
const [brandingSettings, setBrandingSettings] = useState<any>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
const mockPhotos = useMemo(() => generateMockPhotos(12), []);
const mockEvent = {
event_name: 'Preview Wedding Gallery',
event_date: new Date().toISOString(),
expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
};
useEffect(() => {
// Listen for theme preview messages from the branding page
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'THEME_PREVIEW') {
setTheme(event.data.theme);
setBrandingSettings(event.data.branding);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [setTheme]);
// Filter photos
const filteredPhotos = useMemo(() => {
let photos = [...mockPhotos];
// Apply category filter
if (selectedCategoryId) {
photos = photos.filter(photo => photo.category_id === selectedCategoryId);
}
// Apply search filter
if (searchTerm) {
photos = photos.filter(photo =>
photo.filename.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Apply sorting
photos.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.filename.localeCompare(b.filename);
case 'size':
return b.size - a.size;
case 'date':
default:
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
}
});
return photos;
}, [mockPhotos, selectedCategoryId, searchTerm, sortBy]);
// Custom photo renderer for preview
const PreviewPhotoGrid: React.FC<{ photos: any[] }> = ({ photos }) => (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{photos.map((photo) => (
<Card key={photo.id} className="overflow-hidden group cursor-pointer">
<div className="aspect-[4/3] bg-gradient-to-br from-neutral-200 to-neutral-300 relative">
<div className="absolute inset-0 flex items-center justify-center">
<Camera className="w-12 h-12 text-neutral-400" />
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p className="text-white text-xs truncate">{photo.filename}</p>
{photo.category_name && (
<p className="text-white/70 text-xs">{photo.category_name}</p>
)}
</div>
</div>
</Card>
))}
</div>
);
return (
<GalleryLayout
event={mockEvent}
brandingSettings={brandingSettings}
showLogout={false}
showDownloadAll={false}
>
<div className="mt-8">
<div className="text-center mb-6">
<h2 className="text-xl font-semibold text-neutral-900">Theme Preview</h2>
<p className="text-neutral-600">This is how your galleries will look with the current theme settings</p>
</div>
{/* Filters */}
<PhotoFilterBar
categories={mockCategories}
photos={mockPhotos}
selectedCategoryId={selectedCategoryId}
onCategoryChange={setSelectedCategoryId}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortBy={sortBy}
onSortChange={setSortBy}
photoCount={filteredPhotos.length}
/>
{/* Photo Grid */}
<div className="mt-6">
<PreviewPhotoGrid photos={filteredPhotos} />
</div>
</div>
</GalleryLayout>
);
};
+153
View File
@@ -0,0 +1,153 @@
import React, { useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Home } from 'lucide-react';
import DOMPurify from 'dompurify';
import { Loading, Card } from '../../components/common';
import { cmsService } from '../../services/cms.service';
import { api } from '../../config/api';
import '../../styles/prose-overrides.css';
export const LegalPage: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
const { i18n } = useTranslation();
const navigate = useNavigate();
// Extract page slug from pathname if not in params (for static routes like /impressum)
const pathname = window.location.pathname;
const pageSlug = slug || pathname.split('/').pop() || '';
// Fetch settings to get default language
const { data: settingsData } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Use admin settings language
const lang = settingsData?.default_language || 'en';
// Fetch page content
const { data: page, isLoading, error } = useQuery({
queryKey: ['legal-page', pageSlug, lang],
queryFn: () => cmsService.getPublicPage(pageSlug, lang),
enabled: !!pageSlug && pageSlug !== '' && !!settingsData,
});
// Set i18n language when settings are loaded
useEffect(() => {
if (settingsData?.default_language) {
i18n.changeLanguage(settingsData.default_language);
}
}, [settingsData, i18n]);
// Update page title
useEffect(() => {
if (page?.title) {
document.title = `${page.title} - PicPeak`;
}
}, [page?.title]);
if (isLoading) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<Loading size="lg" text="Loading..." />
</div>
);
}
if (error || !page) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<div className="text-center py-12 px-6">
<h2 className="text-xl font-semibold mb-2">Page Not Found</h2>
<p className="text-neutral-600 mb-6">
The page you're looking for doesn't exist.
</p>
<Link
to="/"
className="inline-flex items-center gap-2 text-primary-600 hover:text-primary-700"
>
<Home className="w-4 h-4" />
Go to Homepage
</Link>
</div>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-neutral-50">
{/* Header */}
<header className="bg-white border-b border-neutral-200">
<div className="container py-4">
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 text-neutral-600 hover:text-neutral-900 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{i18n.language === 'de' ? 'Zurück' : 'Back'}
</button>
</div>
</header>
{/* Content */}
<main className="container py-12">
<div className="max-w-4xl mx-auto">
<Card padding="lg">
<h1 className="text-3xl font-bold text-neutral-900 mb-8">{page.title}</h1>
<div
className="prose prose-neutral max-w-none"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(page.content, {
ALLOWED_TAGS: [
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'blockquote', 'a', 'em', 'strong',
'code', 'pre', 'hr', 'div', 'span'
],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'],
ALLOW_DATA_ATTR: false,
KEEP_CONTENT: true,
ADD_TAGS: ['br'], // Explicitly allow br tags
ADD_ATTR: ['style'], // Allow style for text alignment
})
}}
/>
</Card>
</div>
</main>
{/* Footer */}
<footer className="mt-auto py-8 border-t border-neutral-200">
<div className="container text-center">
<div className="flex justify-center gap-4 text-sm">
<Link
to="/impressum"
className="text-neutral-600 hover:text-neutral-900"
>
{lang === 'de' ? 'Impressum' : 'Legal Notice'}
</Link>
<span className="text-neutral-400"></span>
<Link
to="/datenschutz"
className="text-neutral-600 hover:text-neutral-900"
>
{lang === 'de' ? 'Datenschutz' : 'Privacy Policy'}
</Link>
</div>
<p className="text-sm text-neutral-500 mt-4">
© 2024 PicPeak. All rights reserved.
</p>
</div>
</footer>
</div>
);
};