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