Fix brand theme application and add comprehensive translations

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-08 17:07:40 +02:00
parent 2012b0bab9
commit d594d00227
79 changed files with 4570 additions and 329 deletions
+95 -65
View File
@@ -5,7 +5,7 @@ import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { analyticsService } from './services/analytics.service';
import { GalleryAuthProvider } from './contexts';
import { GalleryAuthProvider, MaintenanceProvider } from './contexts';
import { ThemeProvider } from './contexts/ThemeContext';
import { GalleryPage } from './pages/GalleryPage';
import { PreviewPage } from './pages/gallery/PreviewPage';
@@ -25,6 +25,8 @@ import {
} from './pages/admin';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
// Create a client
const queryClient = new QueryClient({
@@ -37,82 +39,110 @@ const queryClient = new QueryClient({
});
function App() {
// Initialize Umami Analytics
// Initialize Umami Analytics based on settings
useEffect(() => {
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
const initializeAnalytics = async () => {
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
if (umamiUrl && umamiWebsiteId) {
try {
// Fetch public settings to check if analytics is enabled
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
const settings = await response.json();
// Only initialize if analytics is enabled in settings
if (settings.enable_analytics !== false) {
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
autoTrack: true,
doNotTrack: true
});
}
} catch (error) {
console.error('Failed to fetch settings for analytics:', error);
// Initialize analytics anyway if settings fetch fails
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
autoTrack: true,
doNotTrack: true
});
}
}
};
if (umamiUrl && umamiWebsiteId) {
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
autoTrack: true,
doNotTrack: true
});
}
initializeAnalytics();
}, []);
return (
<PageErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<DynamicFavicon />
<Router>
<SkipLink />
<Routes>
{/* Public gallery routes */}
<Route path="/gallery/preview" element={<PreviewPage />} />
<Route path="/gallery/:slug/:token?" element={
<GalleryAuthProvider>
<GalleryPage />
</GalleryAuthProvider>
} />
<MaintenanceProvider>
<ThemeProvider>
<GlobalThemeProvider>
<DynamicFavicon />
<Router>
<MaintenanceWrapper>
<SkipLink />
<Routes>
{/* Public gallery routes */}
<Route path="/gallery/preview" element={<PreviewPage />} />
<Route path="/gallery/:slug/:token?" element={
<GalleryAuthProvider>
<GalleryPage />
</GalleryAuthProvider>
} />
{/* Admin routes - wrap with AdminAuthProvider */}
<Route path="/admin" element={<AdminAuthWrapper />}>
<Route path="login" element={<AdminLoginPage />} />
<Route element={<AdminLayout />}>
<Route path="dashboard" element={<AdminDashboard />} />
<Route path="events" element={<EventsListPage />} />
<Route path="events/new" element={<CreateEventPage />} />
<Route path="events/:id" element={<EventDetailsPage />} />
<Route path="archives" element={<ArchivesPage />} />
<Route path="email" element={<EmailConfigPage />} />
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="branding" element={<BrandingPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="cms" element={<CMSPage />} />
<Route index element={<Navigate to="/admin/dashboard" replace />} />
</Route>
</Route>
{/* Admin routes - wrap with AdminAuthProvider */}
<Route path="/admin" element={<AdminAuthWrapper />}>
<Route path="login" element={<AdminLoginPage />} />
<Route element={<AdminLayout />}>
<Route path="dashboard" element={<AdminDashboard />} />
<Route path="events" element={<EventsListPage />} />
<Route path="events/new" element={<CreateEventPage />} />
<Route path="events/:id" element={<EventDetailsPage />} />
<Route path="archives" element={<ArchivesPage />} />
<Route path="email" element={<EmailConfigPage />} />
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="branding" element={<BrandingPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="cms" element={<CMSPage />} />
<Route index element={<Navigate to="/admin/dashboard" replace />} />
</Route>
</Route>
{/* Public legal pages */}
<Route path="/impressum" element={<LegalPage />} />
<Route path="/datenschutz" element={<LegalPage />} />
<Route path="/:slug" element={<LegalPage />} />
{/* Public legal pages */}
<Route path="/impressum" element={<LegalPage />} />
<Route path="/datenschutz" element={<LegalPage />} />
<Route path="/:slug" element={<LegalPage />} />
{/* Default redirect */}
<Route path="/" element={<Navigate to="/admin/login" replace />} />
</Routes>
</Router>
{/* Default redirect */}
<Route path="/" element={<Navigate to="/admin/login" replace />} />
</Routes>
</MaintenanceWrapper>
</Router>
{/* Offline indicator */}
<OfflineIndicator />
{/* Offline indicator */}
<OfflineIndicator />
{/* Toast notifications */}
<ToastContainer
position="bottom-right"
autoClose={5000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="light"
/>
</ThemeProvider>
{/* Toast notifications */}
<ToastContainer
position="bottom-right"
autoClose={5000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="light"
/>
</GlobalThemeProvider>
</ThemeProvider>
</MaintenanceProvider>
</QueryClientProvider>
</PageErrorBoundary>
);
@@ -0,0 +1,33 @@
import React, { useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTheme } from '../contexts/ThemeContext';
import { api } from '../config/api';
interface GlobalThemeProviderProps {
children: React.ReactNode;
}
export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ children }) => {
const { setTheme } = useTheme();
const themeAppliedRef = useRef(false);
// Fetch public settings including theme config
const { data: settingsData } = useQuery({
queryKey: ['global-theme-settings'],
queryFn: async () => {
const response = await api.get('/api/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Apply global theme when settings are loaded
useEffect(() => {
if (!themeAppliedRef.current && settingsData?.theme_config) {
themeAppliedRef.current = true;
setTheme(settingsData.theme_config);
}
}, [settingsData, setTheme]);
return <>{children}</>;
};
+113
View File
@@ -0,0 +1,113 @@
import React, { useEffect } from 'react';
import { AlertTriangle } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { api } from '../config/api';
interface BrandingSettings {
branding_company_name?: string;
branding_company_tagline?: string;
branding_support_email?: string;
branding_footer_text?: string;
branding_favicon_url?: string;
branding_logo_url?: string;
default_language?: string;
}
export const MaintenanceMode: React.FC = () => {
const { t, i18n } = useTranslation();
// Fetch branding settings
const { data: settings } = useQuery<BrandingSettings>({
queryKey: ['public-settings-maintenance'],
queryFn: async () => {
try {
const response = await api.get('/api/public/settings');
return response.data;
} catch (error) {
// Return empty object if settings can't be fetched
return {};
}
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: false, // Don't retry on failure
});
// Set language based on system settings
useEffect(() => {
if (settings?.default_language && settings.default_language !== i18n.language) {
i18n.changeLanguage(settings.default_language);
}
}, [settings?.default_language, i18n]);
return (
<div className="min-h-screen bg-neutral-50 flex flex-col">
{/* Header with branding */}
{(settings?.branding_logo_url || settings?.branding_company_name) && (
<div className="bg-white border-b border-neutral-200 py-4">
<div className="container">
<div className="flex items-center justify-center">
{settings.branding_logo_url ? (
<img
src={settings.branding_logo_url.startsWith('http')
? settings.branding_logo_url
: `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_logo_url}`
}
alt={settings.branding_company_name || 'Company Logo'}
className="h-12 w-auto object-contain"
/>
) : (
<div className="text-center">
<h2 className="text-xl font-semibold text-neutral-800">{settings.branding_company_name}</h2>
{settings.branding_company_tagline && (
<p className="text-sm text-neutral-600">{settings.branding_company_tagline}</p>
)}
</div>
)}
</div>
</div>
</div>
)}
{/* Main content */}
<div className="flex-1 flex items-center justify-center p-4">
<div className="max-w-md w-full text-center">
<div className="inline-flex items-center justify-center w-20 h-20 bg-amber-100 rounded-full mb-6">
<AlertTriangle className="w-10 h-10 text-amber-600" />
</div>
<h1 className="text-3xl font-bold text-neutral-900 mb-4">
{t('maintenance.title')}
</h1>
<p className="text-lg text-neutral-600 mb-8">
{t('maintenance.message')}
</p>
{settings?.branding_support_email && (
<p className="text-sm text-neutral-500 mt-8">
{t('maintenance.urgentMatters')}{' '}
<a
href={`mailto:${settings.branding_support_email}`}
className="text-primary-600 hover:text-primary-700"
>
{settings.branding_support_email}
</a>
</p>
)}
</div>
</div>
{/* Footer */}
{settings?.branding_footer_text && (
<footer className="py-4 border-t border-neutral-200">
<div className="container text-center">
<p className="text-sm text-neutral-500">
{settings.branding_footer_text}
</p>
</div>
</footer>
)}
</div>
);
};
@@ -0,0 +1,59 @@
import React, { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { MaintenanceMode } from './MaintenanceMode';
import { useMaintenanceMode } from '../contexts/MaintenanceContext';
import { setMaintenanceModeCallback, api, getAuthToken } from '../config/api';
interface MaintenanceWrapperProps {
children: React.ReactNode;
}
export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => {
const location = useLocation();
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
// Check if current route is admin route
const isAdminRoute = location.pathname.startsWith('/admin');
const hasAdminAuth = !!getAuthToken(true);
// Register the maintenance mode callback
useEffect(() => {
setMaintenanceModeCallback((enabled: boolean) => {
setMaintenanceMode(enabled);
});
}, [setMaintenanceMode]);
// Check maintenance mode on mount and when location changes
useQuery({
queryKey: ['maintenance-check', location.pathname],
queryFn: async () => {
try {
// Make a lightweight request to check maintenance status
await api.get('/api/public/settings');
// If successful, maintenance mode is off
setMaintenanceMode(false);
return { maintenance: false };
} catch (error: any) {
if (error.response?.status === 503) {
// Only set maintenance mode for non-admin routes or unauthenticated admin routes
if (!isAdminRoute || !hasAdminAuth) {
setMaintenanceMode(true);
return { maintenance: true };
}
}
return { maintenance: false };
}
},
staleTime: 30000, // Check every 30 seconds
retry: false, // Don't retry on failure
enabled: (!isAdminRoute || !hasAdminAuth) && !isMaintenanceMode, // Don't check if already in maintenance
});
// Show maintenance page if in maintenance mode and not on admin route with auth
if (isMaintenanceMode && (!isAdminRoute || !hasAdminAuth)) {
return <MaintenanceMode />;
}
return <>{children}</>;
};
@@ -0,0 +1,93 @@
import React, { useState, useEffect } from 'react';
import { api } from '../../config/api';
interface AdminAuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
fallback?: React.ReactNode;
}
export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = ({
src,
fallback,
alt,
...props
}) => {
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
const loadImage = async () => {
try {
setLoading(true);
setError(false);
// Make authenticated request to get the image
const response = await api.get(src, {
responseType: 'blob',
});
if (!cancelled) {
// Create object URL from blob
const imageUrl = URL.createObjectURL(response.data);
setImageSrc(imageUrl);
setLoading(false);
}
} catch (err: any) {
console.error('Failed to load image:', src, err);
// Log more details about the error
if (err.response) {
console.error('Response status:', err.response.status);
console.error('Response headers:', err.response.headers);
if (err.response.data instanceof Blob) {
// Try to read error message from blob
try {
const text = await err.response.data.text();
console.error('Response data:', text);
} catch (e) {
console.error('Could not read blob data');
}
} else {
console.error('Response data:', err.response.data);
}
}
if (!cancelled) {
setError(true);
setLoading(false);
}
}
};
if (src) {
loadImage();
}
// Cleanup function
return () => {
cancelled = true;
if (imageSrc) {
URL.revokeObjectURL(imageSrc);
}
};
}, [src]);
if (loading) {
return (
<div className="w-full h-full bg-neutral-200 animate-pulse" />
);
}
if (error) {
return fallback ? (
<>{fallback}</>
) : (
<div className="w-full h-full bg-neutral-100 flex items-center justify-center text-neutral-400">
<span className="text-xs">Failed to load</span>
</div>
);
}
return <img src={imageSrc || ''} alt={alt} {...props} />;
};
+94 -33
View File
@@ -1,13 +1,16 @@
import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Menu, User, LogOut, Settings, Bell, Lock } from 'lucide-react';
import { format } from 'date-fns';
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2 } from 'lucide-react';
import { format, formatDistanceToNow } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAdminAuth } from '../../contexts';
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
import { PasswordChangeModal } from './PasswordChangeModal';
import { LanguageSelector } from '../common';
import { notificationsService } from '../../services/notifications.service';
import { toast } from 'react-toastify';
interface AdminHeaderProps {
onMenuClick: () => void;
@@ -20,6 +23,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const [showUserMenu, setShowUserMenu] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const queryClient = useQueryClient();
const userMenuRef = useRef<HTMLDivElement>(null);
const notificationRef = useRef<HTMLDivElement>(null);
@@ -32,21 +36,33 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
navigate('/admin/login');
};
// Mock notifications
const notifications = [
{
id: 1,
type: 'warning',
message: '3 events expiring in the next 7 days',
time: new Date(),
// Fetch notifications
const { data: notificationsData } = useQuery({
queryKey: ['notifications', showNotifications],
queryFn: () => notificationsService.getNotifications(showNotifications, 20),
refetchInterval: 60000, // Refetch every minute
});
// Mark all as read mutation
const markAllAsReadMutation = useMutation({
mutationFn: notificationsService.markAllAsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast.success('All notifications marked as read');
},
{
id: 2,
type: 'success',
message: 'Wedding Smith-Jones archived successfully',
time: new Date(Date.now() - 3600000),
});
// Clear old notifications mutation
const clearOldMutation = useMutation({
mutationFn: notificationsService.clearOldNotifications,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast.success(`Cleared ${data.deletedCount} old notifications`);
},
];
});
const notifications = notificationsData?.notifications || [];
const unreadCount = notificationsData?.unreadCount || 0;
return (
<header className="sticky top-0 z-30 bg-white border-b border-neutral-200">
@@ -79,35 +95,80 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
className="relative p-2 text-neutral-500 hover:text-neutral-700 hover:bg-neutral-100 rounded-lg transition-colors"
>
<Bell className="w-5 h-5" />
{notifications.length > 0 && (
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
)}
</button>
{/* Notifications dropdown */}
{showNotifications && (
<div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border border-neutral-200 py-2">
<div className="px-4 py-2 border-b border-neutral-100">
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-lg border border-neutral-200">
<div className="px-4 py-3 border-b border-neutral-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-900">{t('admin.notifications')}</h3>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={() => markAllAsReadMutation.mutate()}
className="text-xs text-primary-600 hover:text-primary-700 flex items-center gap-1"
title="Mark all as read"
>
<CheckCircle className="w-3 h-3" />
Mark all read
</button>
)}
<button
onClick={() => clearOldMutation.mutate()}
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
title="Clear old notifications"
>
<Trash2 className="w-3 h-3" />
Clear old
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.map((notification) => (
<div
key={notification.id}
className="px-4 py-3 hover:bg-neutral-50 cursor-pointer"
>
<p className="text-sm text-neutral-900">{notification.message}</p>
<p className="text-xs text-neutral-500 mt-1">
{format(notification.time, 'h:mm a')}
</p>
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-neutral-500">
No notifications
</div>
))}
</div>
<div className="px-4 py-2 border-t border-neutral-100">
<button className="text-sm text-primary-600 hover:text-primary-700">
{t('admin.viewAllNotifications')}
</button>
) : (
notifications.map((notification) => {
const style = notificationsService.getNotificationStyle(notification.type);
return (
<div
key={notification.id}
className={`px-4 py-3 hover:bg-neutral-50 cursor-pointer border-l-4 ${
notification.isRead ? 'border-transparent opacity-75' : 'border-primary-500'
}`}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 ${style.color}`}>
<Bell className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-neutral-900">
{notificationsService.formatNotificationMessage(notification)}
</p>
<p className="text-xs text-neutral-500 mt-1">
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
</p>
</div>
</div>
</div>
);
})
)}
</div>
{notifications.length > 0 && (
<div className="px-4 py-2 border-t border-neutral-100 text-center">
<button
onClick={() => setShowNotifications(false)}
className="text-sm text-primary-600 hover:text-primary-700"
>
Close
</button>
</div>
)}
</div>
)}
</div>
@@ -2,12 +2,17 @@ import React, { useState } from 'react';
import { Outlet, Navigate } from 'react-router-dom';
import { useAdminAuth } from '../../contexts';
import { useSessionTimeout } from '../../hooks/useSessionTimeout';
import { AdminSidebar } from './AdminSidebar';
import { AdminHeader } from './AdminHeader';
import { MaintenanceBanner } from './MaintenanceBanner';
export const AdminLayout: React.FC = () => {
const { isAuthenticated, isLoading } = useAdminAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
// Handle session timeout
useSessionTimeout();
if (isLoading) {
return (
@@ -41,6 +46,9 @@ export const AdminLayout: React.FC = () => {
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
{/* Maintenance mode banner */}
<MaintenanceBanner />
{/* Page content */}
<main id="main-content" className="flex-1 px-4 sm:px-6 lg:px-8 py-8">
@@ -0,0 +1,251 @@
import React, { useState } from 'react';
import { Check, Download, Trash2, Eye, Package } from 'lucide-react';
import { toast } from 'react-toastify';
import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service';
import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
interface AdminPhotoGridProps {
photos: AdminPhoto[];
eventId: number;
onPhotoClick: (photo: AdminPhoto, index: number) => void;
onPhotosDeleted: () => void;
}
export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
photos,
eventId,
onPhotoClick,
onPhotosDeleted
}) => {
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deletingPhotoId, setDeletingPhotoId] = useState<number | null>(null);
const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => {
if (e) {
e.stopPropagation();
}
const newSelected = new Set(selectedPhotos);
if (newSelected.has(photoId)) {
newSelected.delete(photoId);
} else {
newSelected.add(photoId);
}
setSelectedPhotos(newSelected);
};
const handleSelectAll = () => {
if (selectedPhotos.size === photos.length) {
setSelectedPhotos(new Set());
} else {
setSelectedPhotos(new Set(photos.map(p => p.id)));
}
};
const handleDeleteSingle = async (photo: AdminPhoto, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm(`Are you sure you want to delete "${photo.filename}"?`)) {
return;
}
setDeletingPhotoId(photo.id);
try {
await photosService.deletePhoto(eventId, photo.id);
toast.success('Photo deleted successfully');
onPhotosDeleted();
} catch (error) {
toast.error('Failed to delete photo');
} finally {
setDeletingPhotoId(null);
}
};
const handleDeleteSelected = async () => {
if (selectedPhotos.size === 0) return;
const count = selectedPhotos.size;
if (!confirm(`Are you sure you want to delete ${count} photo${count > 1 ? 's' : ''}?`)) {
return;
}
setIsDeleting(true);
try {
await photosService.deletePhotos(eventId, Array.from(selectedPhotos));
toast.success(`${count} photo${count > 1 ? 's' : ''} deleted successfully`);
setSelectedPhotos(new Set());
setIsSelectionMode(false);
onPhotosDeleted();
} catch (error) {
toast.error('Failed to delete photos');
} finally {
setIsDeleting(false);
}
};
const handleDownload = async (photo: AdminPhoto, e: React.MouseEvent) => {
e.stopPropagation();
try {
await photosService.downloadPhoto(eventId, photo.id, photo.filename);
toast.success('Download started');
} catch (error) {
toast.error('Failed to download photo');
}
};
const toggleSelectionMode = () => {
setIsSelectionMode(!isSelectionMode);
if (isSelectionMode) {
setSelectedPhotos(new Set());
}
};
return (
<div>
{/* Action Bar */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant={isSelectionMode ? "primary" : "outline"}
size="sm"
onClick={toggleSelectionMode}
leftIcon={<Package className="w-4 h-4" />}
>
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'}
</Button>
{isSelectionMode && (
<>
<Button
variant="ghost"
size="sm"
onClick={handleSelectAll}
>
{selectedPhotos.size === photos.length ? 'Deselect All' : 'Select All'}
</Button>
{selectedPhotos.size > 0 && (
<>
<span className="text-sm text-neutral-600">
{selectedPhotos.size} selected
</span>
<button
onClick={handleDeleteSelected}
disabled={isDeleting}
className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Delete Selected
</button>
</>
)}
</>
)}
</div>
<div className="text-sm text-neutral-600">
{photos.length} photo{photos.length !== 1 ? 's' : ''}
</div>
</div>
{/* Photo Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{photos.map((photo, index) => (
<div
key={photo.id}
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 ${
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
}`}
onClick={() => isSelectionMode ? handlePhotoSelect(photo.id) : onPhotoClick(photo, index)}
>
{/* Selection Checkbox */}
{isSelectionMode && (
<div className="absolute top-2 left-2 z-10">
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center ${
selectedPhotos.has(photo.id)
? 'bg-primary-500 border-primary-500'
: 'bg-white/80 border-neutral-300'
}`}>
{selectedPhotos.has(photo.id) && (
<Check className="w-4 h-4 text-white" />
)}
</div>
</div>
)}
{/* Thumbnail */}
<div className="aspect-square">
{photo.thumbnail_url ? (
<AdminAuthenticatedImage
src={photo.thumbnail_url}
alt={photo.filename}
className="w-full h-full object-cover"
loading="lazy"
fallback={
<div className="w-full h-full flex items-center justify-center text-neutral-400">
<Eye className="w-8 h-8" />
</div>
}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-neutral-400">
<Eye className="w-8 h-8" />
</div>
)}
</div>
{/* Overlay with actions */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
<div className="absolute bottom-0 left-0 right-0 p-3">
<p className="text-white text-xs font-medium truncate mb-1">
{photo.filename}
</p>
<p className="text-white/80 text-xs mb-2">
{photosService.formatBytes(photo.size)}
</p>
{!isSelectionMode && (
<div className="flex gap-1">
<button
onClick={(e) => handleDownload(photo, e)}
className="p-1 text-white hover:bg-white/20 rounded"
>
<Download className="w-3 h-3" />
</button>
<button
onClick={(e) => handleDeleteSingle(photo, e)}
className="p-1 text-white hover:bg-white/20 rounded"
disabled={deletingPhotoId === photo.id}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</div>
</div>
{/* Category Badge */}
{photo.category_name && (
<div className="absolute top-2 right-2">
<span className="px-2 py-1 text-xs font-medium bg-white/90 text-neutral-700 rounded">
{photo.category_name}
</span>
</div>
)}
</div>
))}
</div>
{photos.length === 0 && (
<div className="text-center py-12">
<p className="text-neutral-500">No photos uploaded yet</p>
</div>
)}
</div>
);
};
@@ -0,0 +1,269 @@
import React, { useState } from 'react';
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer } from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'react-toastify';
import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service';
import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
interface AdminPhotoViewerProps {
photos: AdminPhoto[];
initialIndex: number;
eventId: number;
onClose: () => void;
onPhotoDeleted: () => void;
categories: Array<{ id: number; name: string; slug: string }>;
}
export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
photos,
initialIndex,
eventId,
onClose,
onPhotoDeleted,
categories
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [isDeleting, setIsDeleting] = useState(false);
const [showCategoryMenu, setShowCategoryMenu] = useState(false);
const currentPhoto = photos[currentIndex];
const goToPrevious = () => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
};
const goToNext = () => {
setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0));
};
const handleDelete = async () => {
if (!confirm(`Are you sure you want to delete "${currentPhoto.filename}"?`)) {
return;
}
setIsDeleting(true);
try {
await photosService.deletePhoto(eventId, currentPhoto.id);
toast.success('Photo deleted successfully');
// Close viewer if this was the last photo
if (photos.length === 1) {
onClose();
} else {
// Move to next photo if available, otherwise previous
if (currentIndex === photos.length - 1) {
setCurrentIndex(currentIndex - 1);
}
}
onPhotoDeleted();
} catch (error) {
toast.error('Failed to delete photo');
} finally {
setIsDeleting(false);
}
};
const handleDownload = async () => {
try {
await photosService.downloadPhoto(eventId, currentPhoto.id, currentPhoto.filename);
toast.success('Download started');
} catch (error) {
toast.error('Failed to download photo');
}
};
const handleCategoryChange = async (categoryId: number | null) => {
try {
await photosService.updatePhotoCategory(eventId, currentPhoto.id, categoryId);
toast.success('Category updated');
setShowCategoryMenu(false);
// Trigger refresh to update the photo data
onPhotoDeleted(); // This will refresh the photos list
} catch (error) {
toast.error('Failed to update category');
}
};
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
onClose();
break;
case 'ArrowLeft':
goToPrevious();
break;
case 'ArrowRight':
goToNext();
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [currentIndex]);
return (
<div className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center">
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<X className="w-6 h-6" />
</button>
{/* Navigation */}
<button
onClick={goToPrevious}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<ChevronLeft className="w-8 h-8" />
</button>
<button
onClick={goToNext}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<ChevronRight className="w-8 h-8" />
</button>
{/* Main content */}
<div className="flex flex-col lg:flex-row gap-6 max-w-7xl mx-auto p-4 w-full h-full">
{/* Image */}
<div className="flex-1 flex items-center justify-center min-h-0">
<AdminAuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
className="max-w-full max-h-full object-contain"
fallback={
<div className="flex items-center justify-center text-neutral-400">
<div className="text-center">
<Eye className="w-12 h-12 mx-auto mb-2" />
<p className="text-sm">Failed to load image</p>
</div>
</div>
}
/>
</div>
{/* Sidebar */}
<div className="lg:w-80 bg-neutral-900 rounded-lg p-6 overflow-y-auto">
<h3 className="text-white font-medium text-lg mb-4">{currentPhoto.filename}</h3>
{/* Actions */}
<div className="flex gap-2 mb-6">
<Button
variant="primary"
size="sm"
onClick={handleDownload}
leftIcon={<Download className="w-4 h-4" />}
className="flex-1"
>
Download
</Button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="flex-1 px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center justify-center gap-2"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
{/* Category */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-neutral-400 text-sm flex items-center gap-1">
<Tag className="w-4 h-4" />
Category
</span>
<button
onClick={() => setShowCategoryMenu(!showCategoryMenu)}
className="text-xs text-primary-400 hover:text-primary-300"
>
Change
</button>
</div>
<p className="text-white">
{currentPhoto.category_name || 'Uncategorized'}
</p>
{showCategoryMenu && (
<div className="mt-2 bg-neutral-800 rounded-lg p-2">
<button
onClick={() => handleCategoryChange(null)}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-neutral-700 rounded"
>
Uncategorized
</button>
{categories.map(cat => (
<button
key={cat.id}
onClick={() => handleCategoryChange(cat.id)}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-neutral-700 rounded"
>
{cat.name}
</button>
))}
</div>
)}
</div>
{/* Metadata */}
<div className="space-y-4 text-sm">
<div>
<span className="text-neutral-400 flex items-center gap-1 mb-1">
<HardDrive className="w-4 h-4" />
File Size
</span>
<p className="text-white">{photosService.formatBytes(currentPhoto.size)}</p>
</div>
<div>
<span className="text-neutral-400 flex items-center gap-1 mb-1">
<Calendar className="w-4 h-4" />
Uploaded
</span>
<p className="text-white">
{format(new Date(currentPhoto.uploaded_at), 'MMM d, yyyy h:mm a')}
</p>
</div>
{currentPhoto.view_count !== undefined && (
<div>
<span className="text-neutral-400 flex items-center gap-1 mb-1">
<Eye className="w-4 h-4" />
Views
</span>
<p className="text-white">{currentPhoto.view_count}</p>
</div>
)}
{currentPhoto.download_count !== undefined && (
<div>
<span className="text-neutral-400 flex items-center gap-1 mb-1">
<MousePointer className="w-4 h-4" />
Downloads
</span>
<p className="text-white">{currentPhoto.download_count}</p>
</div>
)}
</div>
{/* Navigation info */}
<div className="mt-6 pt-6 border-t border-neutral-700">
<p className="text-neutral-400 text-sm text-center">
{currentIndex + 1} of {photos.length}
</p>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,92 @@
import React from 'react';
import { Archive, AlertTriangle, X } from 'lucide-react';
import { Button, Card } from '../common';
import type { Event } from '../../types';
interface BulkArchiveModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
selectedEvents: Event[];
isLoading?: boolean;
}
export const BulkArchiveModal: React.FC<BulkArchiveModalProps> = ({
isOpen,
onClose,
onConfirm,
selectedEvents,
isLoading = false,
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">Confirm Bulk Archive</h2>
<button
onClick={onClose}
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
disabled={isLoading}
>
<X className="w-5 h-5 text-neutral-500" />
</button>
</div>
<div className="mb-6">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-neutral-700">
<p className="mb-2">
You are about to archive <strong>{selectedEvents.length} event{selectedEvents.length > 1 ? 's' : ''}</strong>.
This action will:
</p>
<ul className="list-disc list-inside space-y-1 text-neutral-600">
<li>Create a ZIP archive of all photos for each event</li>
<li>Make the galleries inaccessible to guests</li>
<li>Remove the events from active listings</li>
<li>Free up storage space by compressing photos</li>
</ul>
</div>
</div>
<div className="border border-neutral-200 rounded-lg max-h-48 overflow-y-auto">
<div className="p-3">
<h3 className="text-sm font-medium text-neutral-700 mb-2">Events to be archived:</h3>
<ul className="space-y-1">
{selectedEvents.map((event) => (
<li key={event.id} className="text-sm text-neutral-600">
{event.event_name} ({event.event_type})
</li>
))}
</ul>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="primary"
onClick={onConfirm}
isLoading={isLoading}
leftIcon={<Archive className="w-4 h-4" />}
>
Archive {selectedEvents.length} Event{selectedEvents.length > 1 ? 's' : ''}
</Button>
</div>
</div>
</Card>
</div>
);
};
BulkArchiveModal.displayName = 'BulkArchiveModal';
@@ -0,0 +1,99 @@
import React from 'react';
import { X, Mail, FileText } from 'lucide-react';
import { Button, Card } from '../common';
interface EmailPreviewModalProps {
isOpen: boolean;
onClose: () => void;
subject: string;
htmlContent: string;
textContent?: string;
}
export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
isOpen,
onClose,
subject,
htmlContent,
textContent
}) => {
const [viewMode, setViewMode] = React.useState<'html' | 'text'>('html');
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
<div className="flex items-center gap-3">
<Mail className="w-6 h-6 text-primary-600" />
<h2 className="text-xl font-semibold text-neutral-900">Email Preview</h2>
</div>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Subject */}
<div className="px-6 py-4 border-b border-neutral-200 bg-neutral-50">
<p className="text-sm font-medium text-neutral-600">Subject:</p>
<p className="text-lg font-semibold text-neutral-900 mt-1">{subject}</p>
</div>
{/* View mode toggle */}
<div className="px-6 py-3 border-b border-neutral-200">
<div className="flex gap-2">
<Button
variant={viewMode === 'html' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('html')}
leftIcon={<Mail className="w-4 h-4" />}
>
HTML View
</Button>
{textContent && (
<Button
variant={viewMode === 'text' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('text')}
leftIcon={<FileText className="w-4 h-4" />}
>
Text View
</Button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{viewMode === 'html' ? (
<div className="bg-white border border-neutral-200 rounded-lg shadow-sm">
<iframe
srcDoc={htmlContent}
className="w-full h-[600px] border-0"
title="Email Preview"
/>
</div>
) : (
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-6">
<pre className="whitespace-pre-wrap font-mono text-sm text-neutral-700">
{textContent}
</pre>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-6 border-t border-neutral-200">
<Button variant="outline" onClick={onClose}>
Close
</Button>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,42 @@
import React from 'react';
import { AlertTriangle, X } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { settingsService } from '../../services/settings.service';
export const MaintenanceBanner: React.FC = () => {
const [dismissed, setDismissed] = React.useState(false);
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings(),
refetchInterval: 60000 // Check every minute
});
const isMaintenanceMode = settings?.general_maintenance_mode === true ||
settings?.general_maintenance_mode === 'true';
if (!isMaintenanceMode || dismissed) {
return null;
}
return (
<div className="bg-amber-50 border-b border-amber-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600" />
<p className="text-sm font-medium text-amber-900">
Maintenance mode is currently enabled. Public access to galleries is restricted.
</p>
</div>
<button
onClick={() => setDismissed(true)}
className="text-amber-600 hover:text-amber-700"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,170 @@
import React, { useState } from 'react';
import { X, Key, Copy, CheckCircle, Mail } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card } from '../common';
interface PasswordResetModalProps {
eventName: string;
onConfirm: (sendEmail: boolean) => Promise<{ newPassword: string; emailSent: boolean }>;
onClose: () => void;
}
export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
eventName,
onConfirm,
onClose
}) => {
const [isResetting, setIsResetting] = useState(false);
const [sendEmail, setSendEmail] = useState(true);
const [newPassword, setNewPassword] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleReset = async () => {
setIsResetting(true);
try {
const result = await onConfirm(sendEmail);
setNewPassword(result.newPassword);
toast.success('Password reset successfully');
} catch (error) {
toast.error('Failed to reset password');
onClose();
} finally {
setIsResetting(false);
}
};
const handleCopy = async () => {
if (newPassword) {
await navigator.clipboard.writeText(newPassword);
setCopied(true);
toast.success('Password copied to clipboard');
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-md w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">
{newPassword ? 'New Password' : 'Reset Gallery Password'}
</h2>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600"
>
<X className="w-5 h-5" />
</button>
</div>
{!newPassword ? (
<>
<p className="text-neutral-600 mb-6">
Are you sure you want to reset the password for <strong>{eventName}</strong>?
This will generate a new password for gallery access.
</p>
<div className="mb-6">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={sendEmail}
onChange={(e) => setSendEmail(e.target.checked)}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 focus:ring-2"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-700">
Send email notification
</span>
</div>
<p className="text-xs text-neutral-500 mt-1">
Notify the host about the password change
</p>
</div>
</label>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6">
<p className="text-sm text-amber-800">
<strong>Note:</strong> The old password will no longer work.
Make sure to share the new password with the host.
</p>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={isResetting}
className="flex-1"
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleReset}
disabled={isResetting}
isLoading={isResetting}
leftIcon={<Key className="w-4 h-4" />}
className="flex-1"
>
Reset Password
</Button>
</div>
</>
) : (
<>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<div className="flex items-center gap-3 mb-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<p className="font-medium text-green-900">Password reset successfully!</p>
</div>
{sendEmail && (
<p className="text-sm text-green-700">
An email notification has been sent to the host.
</p>
)}
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-neutral-700 mb-2">
New Gallery Password
</label>
<div className="flex gap-2">
<input
type="text"
value={newPassword}
readOnly
className="flex-1 px-3 py-2 bg-neutral-50 border border-neutral-300 rounded-lg font-mono text-sm"
/>
<Button
variant="outline"
onClick={handleCopy}
leftIcon={copied ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
>
{copied ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-6">
<p className="text-sm text-blue-800">
<strong>Important:</strong> Save this password securely. It cannot be recovered once you close this window.
</p>
</div>
<Button
variant="primary"
onClick={onClose}
className="w-full"
>
Done
</Button>
</>
)}
</Card>
</div>
);
};
@@ -0,0 +1,89 @@
import React from 'react';
import { Search, Filter, SortAsc, SortDesc } from 'lucide-react';
import { Input } from '../common';
interface PhotoFiltersProps {
categories: Array<{ id: number; name: string; slug: string }>;
selectedCategory: number | null | undefined;
searchTerm: string;
sortBy: 'date' | 'name' | 'size';
sortOrder: 'asc' | 'desc';
onCategoryChange: (categoryId: number | null | undefined) => void;
onSearchChange: (search: string) => void;
onSortChange: (sort: 'date' | 'name' | 'size', order: 'asc' | 'desc') => void;
}
export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
categories,
selectedCategory,
searchTerm,
sortBy,
sortOrder,
onCategoryChange,
onSearchChange,
onSortChange
}) => {
const handleSortToggle = () => {
onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc');
};
return (
<div className="bg-white border border-neutral-200 rounded-lg p-4 mb-6">
<div className="flex flex-col lg:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<Input
type="text"
placeholder="Search by filename..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
/>
</div>
{/* Category Filter */}
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-neutral-400" />
<select
value={selectedCategory === null ? '' : selectedCategory || ''}
onChange={(e) => onCategoryChange(e.target.value === '' ? null : Number(e.target.value) || undefined)}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">All Categories</option>
<option value="0">Uncategorized</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
{/* Sort Options */}
<div className="flex items-center gap-2">
<select
value={sortBy}
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size', sortOrder)}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="date">Sort by Date</option>
<option value="name">Sort by Name</option>
<option value="size">Sort by Size</option>
</select>
<button
onClick={handleSortToggle}
className="p-2 border border-neutral-300 rounded-lg hover:bg-neutral-50 transition-colors"
aria-label={sortOrder === 'asc' ? 'Sort descending' : 'Sort ascending'}
>
{sortOrder === 'asc' ? (
<SortAsc className="w-5 h-5 text-neutral-600" />
) : (
<SortDesc className="w-5 h-5 text-neutral-600" />
)}
</button>
</div>
</div>
</div>
);
};
@@ -61,9 +61,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
try {
const response = await api.post(`/api/admin/events/${eventId}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
// Don't set Content-Type header - axios will set it with the boundary
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
+9 -1
View File
@@ -7,4 +7,12 @@ export { AdminAuthWrapper } from './AdminAuthWrapper';
export { PhotoUpload } from './PhotoUpload';
export { CategoryManager } from './CategoryManager';
export { EventCategoryManager } from './EventCategoryManager';
export { CMSEditor } from './CMSEditor';
export { CMSEditor } from './CMSEditor';
export { BulkArchiveModal } from './BulkArchiveModal';
export { MaintenanceBanner } from './MaintenanceBanner';
export { EmailPreviewModal } from './EmailPreviewModal';
export { AdminPhotoGrid } from './AdminPhotoGrid';
export { AdminPhotoViewer } from './AdminPhotoViewer';
export { PhotoFilters } from './PhotoFilters';
export { PasswordResetModal } from './PasswordResetModal';
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
@@ -54,8 +54,12 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
}
}
console.log('Fetching authenticated image:', imageUrl);
const response = await fetch(imageUrl, {
// Prepend API URL for absolute paths
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const fullImageUrl = imageUrl.startsWith('/') ? `${apiUrl}${imageUrl}` : imageUrl;
console.log('Fetching authenticated image:', fullImageUrl);
const response = await fetch(fullImageUrl, {
headers: {
'Authorization': `Bearer ${token}`
}
@@ -0,0 +1,54 @@
import React, { useEffect, useState } from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import { useQuery } from '@tanstack/react-query';
interface ReCaptchaProps {
onChange: (token: string | null) => void;
onExpired?: () => void;
size?: 'normal' | 'compact';
}
export const ReCaptcha: React.FC<ReCaptchaProps> = ({
onChange,
onExpired,
size = 'normal'
}) => {
const recaptchaRef = React.useRef<ReCAPTCHA>(null);
const [siteKey, setSiteKey] = useState<string>('');
// Fetch public settings to get reCAPTCHA site key
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
return response.json();
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
useEffect(() => {
if (settings?.recaptcha_site_key) {
setSiteKey(settings.recaptcha_site_key);
}
}, [settings]);
// If reCAPTCHA is not enabled or site key is not available, return null
if (!settings?.enable_recaptcha || !siteKey) {
return null;
}
return (
<div className="flex justify-center">
<ReCAPTCHA
ref={recaptchaRef}
sitekey={siteKey}
onChange={onChange}
onExpired={onExpired}
size={size}
theme="light"
/>
</div>
);
};
export default ReCaptcha;
+2 -1
View File
@@ -15,4 +15,5 @@ export { OfflineIndicator, useOnlineStatus } from './OfflineIndicator';
export { SkipLink } from './SkipLink';
export { DynamicFavicon } from './DynamicFavicon';
export { LanguageSelector } from './LanguageSelector';
export { AuthenticatedImage } from './AuthenticatedImage';
export { AuthenticatedImage } from './AuthenticatedImage';
export { ReCaptcha } from './ReCaptcha';
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
import { Calendar, Clock, Download, LogOut } from 'lucide-react';
import { format, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { Button, LanguageSelector } from '../common';
import { Button } from '../common';
import { DynamicFavicon } from '../common/DynamicFavicon';
interface GalleryLayoutProps {
@@ -56,7 +56,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{brandingSettings?.logo_url && (
<div className="pr-4 border-r border-neutral-200">
<img
src={brandingSettings.logo_url}
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}`}
alt={brandingSettings.company_name || 'Company Logo'}
className="h-12 w-auto object-contain"
/>
@@ -94,7 +94,6 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
<div className="flex items-center gap-2">
{headerExtra}
<LanguageSelector />
{showDownloadAll && onDownloadAll && (
<Button
variant="primary"
@@ -231,14 +231,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
)}
{/* Welcome Message */}
{event.welcome_message && (
<div className="mt-6">
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
<p className="text-primary-900">{event.welcome_message}</p>
</div>
</div>
)}
{/* Search and Filters */}
<div className="mt-6">
+20
View File
@@ -5,6 +5,13 @@ import Cookies from 'js-cookie';
export const ADMIN_TOKEN_KEY = 'admin_token';
export const GALLERY_TOKEN_KEY = 'gallery_token';
// Maintenance mode callback
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void) => {
maintenanceModeCallback = callback;
};
// Create axios instance
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001',
@@ -42,6 +49,19 @@ api.interceptors.request.use(
api.interceptors.response.use(
(response) => response,
(error) => {
// Handle maintenance mode (503)
if (error.response?.status === 503) {
const isAdminRoute = error.config?.url?.includes('/admin');
const hasAdminAuth = error.config?.headers?.Authorization?.startsWith('Bearer ');
// Only trigger maintenance mode for non-admin routes or unauthenticated admin routes
if (!isAdminRoute || !hasAdminAuth) {
if (maintenanceModeCallback) {
maintenanceModeCallback(true);
}
}
}
if (error.response?.status === 401) {
// Clear tokens on unauthorized
Cookies.remove(ADMIN_TOKEN_KEY);
+3 -3
View File
@@ -16,7 +16,7 @@ interface GalleryEvent {
interface GalleryAuthContextType {
isAuthenticated: boolean;
event: GalleryEvent | null;
login: (slug: string, password: string) => Promise<void>;
login: (slug: string, password: string, recaptchaToken?: string | null) => Promise<void>;
logout: () => void;
isLoading: boolean;
error: string | null;
@@ -62,11 +62,11 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
setIsLoading(false);
}, []);
const login = async (slug: string, password: string) => {
const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
try {
setError(null);
setIsLoading(true);
const response = await authService.verifyGalleryPassword(slug, password);
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
setEvent(response.event);
setIsAuthenticated(true);
@@ -0,0 +1,74 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { setMaintenanceModeCallback } from '../config/api';
interface MaintenanceContextType {
isMaintenanceMode: boolean;
setMaintenanceMode: (enabled: boolean) => void;
}
const MaintenanceContext = createContext<MaintenanceContextType | undefined>(undefined);
export const useMaintenanceMode = () => {
const context = useContext(MaintenanceContext);
if (!context) {
throw new Error('useMaintenanceMode must be used within MaintenanceProvider');
}
return context;
};
interface MaintenanceProviderProps {
children: ReactNode;
}
export const MaintenanceProvider: React.FC<MaintenanceProviderProps> = ({ children }) => {
const [isMaintenanceMode, setIsMaintenanceMode] = useState(false);
// Check maintenance mode status on mount
const { data: settings } = useQuery({
queryKey: ['public-settings-maintenance'],
queryFn: async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
if (response.status === 503) {
setIsMaintenanceMode(true);
return null;
}
return response.json();
} catch (error) {
// If we can't reach the server, don't assume maintenance mode
return null;
}
},
staleTime: 30 * 1000, // Check every 30 seconds
refetchInterval: 30 * 1000,
});
// Update maintenance mode based on settings
useEffect(() => {
if (settings?.maintenance_mode !== undefined) {
setIsMaintenanceMode(settings.maintenance_mode);
}
}, [settings]);
// Set up the callback for API interceptor
useEffect(() => {
setMaintenanceModeCallback((enabled: boolean) => {
setIsMaintenanceMode(enabled);
});
return () => {
setMaintenanceModeCallback(null as any);
};
}, []);
const setMaintenanceMode = (enabled: boolean) => {
setIsMaintenanceMode(enabled);
};
return (
<MaintenanceContext.Provider value={{ isMaintenanceMode, setMaintenanceMode }}>
{children}
</MaintenanceContext.Provider>
);
};
+2 -1
View File
@@ -1,4 +1,5 @@
export { GalleryAuthProvider, useGalleryAuth } from './GalleryAuthContext';
export { AdminAuthProvider, useAdminAuth } from './AdminAuthContext';
export { ThemeProvider, useTheme, PRESET_THEMES } from './ThemeContext';
export type { ThemeConfig, EventTheme } from './ThemeContext';
export type { ThemeConfig, EventTheme } from './ThemeContext';
export { MaintenanceProvider, useMaintenanceMode } from './MaintenanceContext';
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useCallback } from 'react';
import { useAdminAuth } from '../contexts';
import { api } from '../config/api';
// Hook to handle session timeout
export const useSessionTimeout = () => {
const { logout } = useAdminAuth();
const handleSessionTimeout = useCallback((error: any) => {
if (error?.response?.data?.code === 'SESSION_TIMEOUT') {
// Clear local auth state
logout();
// Redirect to login with message
window.location.href = '/admin/login?session=expired';
return true;
}
return false;
}, [logout]);
useEffect(() => {
// Add response interceptor to handle session timeout
const interceptor = api.interceptors.response.use(
response => response,
error => {
if (handleSessionTimeout(error)) {
// Don't propagate the error if it was a session timeout
return Promise.reject(new Error('Session expired'));
}
return Promise.reject(error);
}
);
// Clean up interceptor on unmount
return () => {
api.interceptors.response.eject(interceptor);
};
}, [handleSessionTimeout]);
return { handleSessionTimeout };
};
+262 -7
View File
@@ -22,7 +22,25 @@
"uploading": "Wird hochgeladen...",
"uploaded": "Hochgeladen",
"photo": "Foto",
"photos": "Fotos"
"photos": "Fotos",
"restore": "Wiederherstellen",
"actions": "Aktionen",
"refresh": "Aktualisieren",
"preview": "Vorschau",
"processing": "Wird verarbeitet...",
"upload": "Hochladen"
},
"upload": {
"photoCategory": "Fotokategorie",
"noCategory": "Keine Kategorie",
"eventSpecific": "(Veranstaltungsspezifisch)",
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei)",
"selectedFiles": "Ausgewählte Dateien",
"uploading": "Wird hochgeladen...",
"uploadComplete": "Upload abgeschlossen!",
"uploadFailed": "Upload fehlgeschlagen",
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden"
},
"navigation": {
"dashboard": "Dashboard",
@@ -34,6 +52,46 @@
"emailSettings": "E-Mail-Einstellungen",
"cmsPages": "CMS-Seiten"
},
"archives": {
"title": "Archive",
"subtitle": "Archivierte Fotogalerien verwalten",
"loadingArchives": "Archive werden geladen...",
"totalArchives": "Gesamtarchive",
"storageUsed": "Genutzter Speicher",
"totalPhotos": "Gesamtfotos",
"avgArchiveSize": "Durchschn. Archivgröße",
"searchPlaceholder": "Archive durchsuchen...",
"allTypes": "Alle Typen",
"wedding": "Hochzeit",
"birthday": "Geburtstag",
"corporate": "Geschäftlich",
"other": "Andere",
"sortByDate": "Nach Datum sortieren",
"sortByName": "Nach Name sortieren",
"sortBySize": "Nach Größe sortieren",
"tableHeaders": {
"event": "Veranstaltung",
"type": "Typ",
"archivedDate": "Archivierungsdatum",
"size": "Größe",
"photos": "Fotos",
"actions": "Aktionen"
},
"noArchivesFound": "Keine Archive gefunden",
"eventDateNA": "Veranstaltungsdatum: N/A",
"processing": "Wird verarbeitet...",
"download": "Herunterladen",
"restore": "Wiederherstellen",
"delete": "Löschen",
"showing": "Zeige {{from}} bis {{to}} von {{total}} Archiven",
"page": "Seite {{current}} von {{total}}",
"storageManagement": "Speicherverwaltung",
"storageInfo": "Archive werden dauerhaft gespeichert, es sei denn, sie werden manuell gelöscht. Erwägen Sie die Implementierung einer Aufbewahrungsrichtlinie zur Verwaltung der Speicherkosten.",
"confirmRestore": "Sind Sie sicher, dass Sie dieses Archiv wiederherstellen möchten? Die Veranstaltung wird wieder aktiv.",
"confirmDelete": "Sind Sie sicher, dass Sie dieses Archiv dauerhaft löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"restoreSuccess": "Archiv erfolgreich wiederhergestellt",
"deleteSuccess": "Archiv dauerhaft gelöscht"
},
"auth": {
"login": "Anmelden",
"password": "Passwort",
@@ -120,17 +178,78 @@
"uploadPhotos": "Fotos hochladen",
"archiveEvent": "Veranstaltung archivieren",
"archiveConfirm": "Sind Sie sicher, dass Sie diese Veranstaltung archivieren möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"extendExpiration": "Um {{days}} Tage verlängern"
"extendExpiration": "Um {{days}} Tage verlängern",
"backToEvents": "Zurück zu Veranstaltungen",
"loadingEventDetails": "Veranstaltungsdetails werden geladen...",
"saveChanges": "Änderungen speichern",
"eventExpired": "Diese Veranstaltung ist abgelaufen",
"eventExpiresIn": "Diese Veranstaltung läuft in {{days}} Tagen ab",
"guestsNoAccess": "Gäste können nicht mehr auf die Galerie zugreifen. Erwägen Sie, diese Veranstaltung zu archivieren.",
"warningEmailsSent": "Warn-E-Mails wurden an den Gastgeber gesendet.",
"overview": "Übersicht",
"photos": "Fotos",
"categories": "Kategorien",
"eventInformation": "Veranstaltungsinformationen",
"welcomeMessage": "Willkommensnachricht",
"noWelcomeMessage": "Keine Willkommensnachricht festgelegt",
"created": "Erstellt",
"expires": "Läuft ab",
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.",
"resetGalleryPassword": "Galerie-Passwort zurücksetzen",
"photoStatistics": "Fotostatistiken",
"managePhotos": "Fotos verwalten",
"actions": "Aktionen",
"archivingInfo": "Beim Archivieren wird eine ZIP-Datei aller Fotos erstellt und die Galerie aus dem öffentlichen Zugriff entfernt.",
"statistics": "Statistiken",
"views": "Aufrufe",
"downloads": "Downloads",
"uniqueVisitors": "Eindeutige Besucher",
"noStatistics": "Noch keine Statistiken verfügbar",
"archiveStatus": "Archivstatus",
"archivedOn": "Archiviert am",
"downloadArchive": "Archiv herunterladen",
"loadingPhotos": "Fotos werden geladen...",
"photoCategories": "Fotokategorien",
"organizeCategoriesInfo": "Organisieren Sie Ihre Fotos in Kategorien. Kategorien helfen Gästen, bestimmte Fototypen zu navigieren und zu finden.",
"categoriesTip": "Tipp: Kategorien sind spezifisch für jede Veranstaltung. Sie können auch globale Kategorien in den Einstellungen erstellen.",
"contactInformation": "Kontaktinformationen",
"hostEmailHelp": "Erhält Benachrichtigungen zur Galerie-Erstellung und zum Ablauf",
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
"securityAccess": "Sicherheit & Zugriff",
"galleryPassword": "Galerie-Passwort",
"confirmPassword": "Passwort bestätigen",
"showPasswords": "Passwörter anzeigen",
"gallerySettings": "Galerie-Einstellungen",
"colorTheme": "Farbthema",
"galleryExpiresIn": "Galerie läuft ab in",
"galleryWillExpireOn": "Galerie läuft ab am {{date}}",
"expirationWarning": "Gäste erhalten 7 Tage vor Ablauf eine Warn-E-Mail.",
"processingRequest": "Ihre Anfrage wird verarbeitet...",
"eventTypeWedding": "Hochzeit",
"eventTypeBirthday": "Geburtstag",
"eventTypeCorporate": "Geschäftlich",
"eventTypeOther": "Andere",
"days30": "30 Tage",
"days60": "60 Tage",
"days90": "90 Tage",
"days365": "1 Jahr",
"createNewEvent": "Neue Veranstaltung erstellen",
"setupNewGallery": "Richten Sie eine neue Fotogalerie für Ihre Veranstaltung ein",
"adminNotificationEmail": "Admin-Benachrichtigungs-E-Mail"
},
"settings": {
"title": "Systemeinstellungen",
"subtitle": "Systemweite Einstellungen und Präferenzen konfigurieren",
"loadingSettings": "Einstellungen werden geladen...",
"general": {
"title": "Allgemein",
"siteConfiguration": "Website-Konfiguration",
"siteUrl": "Website-URL",
"siteUrlHelp": "Wird für die Generierung von Galerielinks in E-Mails verwendet",
"defaultExpiration": "Standardablauf (Tage)",
"defaultExpirationHelp": "Wie lange Galerien standardmäßig aktiv bleiben",
"maxFileSize": "Max. Dateigröße (MB)",
"maxFileSizeHelp": "Maximale Größe pro hochgeladenem Foto",
"allowedFileTypes": "Erlaubte Dateitypen",
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
"featureToggles": "Funktionsschalter",
@@ -139,7 +258,10 @@
"enableRegistration": "Selbstregistrierung für Admins erlauben",
"maintenanceMode": "Wartungsmodus aktivieren",
"language": "Sprache",
"saveSettings": "Allgemeine Einstellungen speichern"
"defaultLanguage": "Standardsprache",
"defaultLanguageHelp": "Sprache, die Gästen vor der Anmeldung angezeigt wird",
"saveSettings": "Allgemeine Einstellungen speichern",
"saveGeneralSettings": "Allgemeine Einstellungen speichern"
},
"storage": {
"title": "Speicher",
@@ -150,23 +272,31 @@
"storageUsage": "Speichernutzung",
"storageByEvent": "Speicher nach Veranstaltung",
"storageManagement": "Speicherverwaltung",
"storageManagementHelp": "Erwägen Sie, alte Veranstaltungen zu archivieren oder zu löschen, um Speicherplatz freizugeben. Archivierte Veranstaltungen sind komprimiert und benötigen weniger Speicher als aktive Galerien."
"storageManagementHelp": "Erwägen Sie, alte Veranstaltungen zu archivieren oder zu löschen, um Speicherplatz freizugeben. Archivierte Veranstaltungen sind komprimiert und benötigen weniger Speicher als aktive Galerien.",
"noEventsUsingStorage": "Keine Veranstaltungen verwenden Speicher",
"unlimited": "Unbegrenzt"
},
"security": {
"title": "Sicherheit",
"passwordSettings": "Passworteinstellungen",
"requirePassword": "Passwort für alle Galerien erforderlich",
"minPasswordLength": "Minimale Passwortlänge",
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
"sessionAuth": "Sitzung & Authentifizierung",
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
"maxLoginAttempts": "Max. Anmeldeversuche",
"maxLoginAttemptsHelp": "Maximale fehlgeschlagene Anmeldeversuche vor Sperrung",
"enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren",
"recaptchaSettings": "reCAPTCHA-Einstellungen",
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
"siteKey": "Site-Schlüssel",
"siteKeyHelp": "Ihr reCAPTCHA v2 Site-Schlüssel (öffentlich)",
"secretKey": "Geheimer Schlüssel",
"secretKeyHelp": "Ihr reCAPTCHA v2 Geheimschlüssel (privat halten)",
"recaptchaHelp": "Holen Sie sich Ihre reCAPTCHA-Schlüssel von",
"saveSettings": "Sicherheitseinstellungen speichern"
"saveSettings": "Sicherheitseinstellungen speichern",
"saveSecuritySettings": "Sicherheitseinstellungen speichern"
},
"categories": {
"title": "Kategorien",
@@ -175,32 +305,63 @@
}
},
"branding": {
"title": "Branding & Anpassung",
"title": "Branding & Themen",
"titleFull": "Branding & Anpassung",
"subtitle": "Passen Sie das Aussehen Ihrer Galerien an",
"loadingBranding": "Branding-Einstellungen werden geladen...",
"companyInfo": "Unternehmensinformationen",
"companyName": "Unternehmensname",
"companyNameHelp": "Wird in Galerie-Headern und E-Mails angezeigt",
"companyTagline": "Unternehmens-Slogan",
"companyTaglineHelp": "Eine kurze Beschreibung Ihres Unternehmens",
"supportEmail": "Support-E-Mail",
"supportEmailHelp": "Kontakt-E-Mail für Gäste-Support",
"footerText": "Fußzeilentext",
"footerTextHelp": "Wird am unteren Rand der Galerien angezeigt",
"logo": "Logo",
"currentLogo": "Aktuelles Logo",
"uploadLogo": "Logo hochladen",
"removeLogo": "Logo entfernen",
"logoHelp": "Empfohlene Größe: 200x60px, PNG oder JPEG",
"favicon": "Favicon",
"currentFavicon": "Aktuelles Favicon",
"uploadFavicon": "Favicon hochladen",
"removeFavicon": "Favicon entfernen",
"faviconHelp": "PNG- oder ICO-Format, empfohlene Größe: 32x32px",
"watermark": "Wasserzeichen",
"watermarkSettings": "Wasserzeichen-Einstellungen",
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
"enableWatermarks": "Wasserzeichen aktivieren",
"watermarkHelp": "Fügen Sie Ihren Firmennamen als Wasserzeichen auf heruntergeladenen Fotos hinzu",
"watermarkLogo": "Wasserzeichen-Logo",
"currentWatermark": "Aktuelles Wasserzeichen",
"uploadWatermarkLogo": "Wasserzeichen-Logo hochladen",
"watermarkPosition": "Wasserzeichen-Position",
"topLeft": "Oben Links",
"topRight": "Oben Rechts",
"center": "Mitte",
"bottomLeft": "Unten Links",
"bottomRight": "Unten Rechts",
"watermarkOpacity": "Wasserzeichen-Transparenz",
"watermarkSize": "Wasserzeichen-Größe",
"theme": "Theme",
"galleryTheme": "Galerie-Theme",
"themeCustomization": "Theme-Anpassung",
"selectPreset": "Vorgefertigtes Theme auswählen",
"colors": "Farben",
"primaryColor": "Primärfarbe",
"secondaryColor": "Sekundärfarbe",
"accentColor": "Akzentfarbe",
"backgroundColor": "Hintergrundfarbe",
"textColor": "Textfarbe",
"customCSS": "Benutzerdefiniertes CSS",
"preview": "Vorschau",
"previewInNewTab": "Vorschau in neuem Tab",
"reset": "Zurücksetzen",
"saveChanges": "Änderungen speichern"
"saveChanges": "Änderungen speichern",
"applyLivePreview": "Änderungen sofort anwenden (Live-Vorschau)",
"eventSpecificThemes": "Veranstaltungsspezifische Themen",
"eventThemesInfo": "Sie können diese globalen Theme-Einstellungen für einzelne Veranstaltungen beim Erstellen oder Bearbeiten überschreiben."
},
"admin": {
"title": "Admin-Panel",
@@ -214,6 +375,10 @@
"storagePercent": "{{percent}}% von {{limit}}",
"notifications": "Benachrichtigungen",
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
"noNotifications": "Keine neuen Benachrichtigungen",
"markAsRead": "Als gelesen markieren",
"markAllAsRead": "Alle als gelesen markieren",
"notificationSettings": "Benachrichtigungseinstellungen",
"changePassword": "Passwort ändern",
"loadingDashboard": "Dashboard wird geladen...",
"activeEvents": "Aktive Veranstaltungen",
@@ -238,6 +403,8 @@
"notFound": "Nicht gefunden",
"galleryNotFound": "Galerie nicht gefunden",
"galleryNotFoundMessage": "Diese Galerie existiert nicht oder wurde entfernt.",
"galleryArchived": "Galerie archiviert",
"galleryArchivedMessage": "Diese Galerie wurde archiviert und ist nicht mehr zugänglich. Bitte kontaktieren Sie den Veranstalter, wenn Sie Zugriff auf diese Fotos benötigen.",
"unauthorized": "Nicht autorisiert",
"forbidden": "Verboten",
"serverError": "Serverfehler",
@@ -280,5 +447,93 @@
"pageUpdated": "Seite erfolgreich aktualisiert",
"archiveRestored": "Archiv erfolgreich wiederhergestellt",
"archiveDeleted": "Archiv dauerhaft gelöscht"
},
"analytics": {
"title": "Analytics Dashboard",
"titleSimple": "Analytik",
"subtitle": "Galerie-Performance und Besucherengagement verfolgen",
"detailedSubtitle": "Detaillierte Analysen mit Umami",
"loadingAnalytics": "Analytik wird geladen...",
"showSummaryView": "Zusammenfassungsansicht anzeigen",
"fullDashboard": "Vollständiges Dashboard",
"refresh": "Aktualisieren",
"last7Days": "Letzte 7 Tage",
"last30Days": "Letzte 30 Tage",
"last90Days": "Letzte 90 Tage",
"pageViews": "Seitenaufrufe",
"uniqueVisitors": "Eindeutige Besucher",
"totalDownloads": "Gesamte Downloads",
"topGallery": "Top-Galerie",
"topPages": "Top-Seiten",
"views": "Aufrufe",
"visitors": "eindeutige Besucher",
"topDownloadsByGallery": "Top-Downloads nach Galerie",
"deviceBreakdown": "Geräteaufschlüsselung",
"desktop": "Desktop",
"mobile": "Mobil",
"tablet": "Tablet",
"storageUsage": "Speichernutzung",
"used": "Verwendet",
"of": "von",
"totalPhotos": "Gesamte Fotos",
"activeEvents": "Aktive Veranstaltungen",
"notConfigured": "Umami Analytics nicht konfiguriert",
"configureInstructions": "Um echte Analysedaten zu sehen, konfigurieren Sie Umami in Ihren Umgebungsvariablen und Admin-Panel-Einstellungen.",
"noData": "Keine Daten verfügbar",
"percentChange": "{{percent}}% gegenüber letztem Zeitraum"
},
"email": {
"title": "E-Mail-Konfiguration",
"subtitle": "E-Mail-Einstellungen für Benachrichtigungen konfigurieren",
"loadingSettings": "E-Mail-Einstellungen werden geladen...",
"smtpConfiguration": "SMTP-Konfiguration",
"smtpHost": "SMTP-Host",
"smtpHostHelp": "Ihr E-Mail-Server-Hostname",
"smtpPort": "SMTP-Port",
"smtpPortHelp": "Normalerweise 587 für TLS, 465 für SSL, 25 für unverschlüsselt",
"smtpSecure": "SSL/TLS verwenden",
"smtpSecureHelp": "Für sichere E-Mail-Übertragung aktivieren",
"smtpUsername": "SMTP-Benutzername",
"smtpUsernameHelp": "Ihr E-Mail-Konto-Benutzername",
"smtpPassword": "SMTP-Passwort",
"smtpPasswordHelp": "Ihr E-Mail-Konto-Passwort",
"fromDetails": "Absenderdetails",
"fromEmail": "Absender-E-Mail",
"fromEmailHelp": "E-Mail-Adresse, die als Absender erscheint",
"fromName": "Absendername",
"fromNameHelp": "Name, der als Absender erscheint",
"testConfiguration": "Konfiguration testen",
"testEmail": "Test-E-Mail-Adresse",
"testEmailHelp": "Senden Sie eine Test-E-Mail zur Überprüfung der Einstellungen",
"sendTestEmail": "Test-E-Mail senden",
"saveConfiguration": "Konfiguration speichern",
"emailTemplates": "E-Mail-Vorlagen",
"templateVariables": "Verfügbare Variablen",
"previewTemplate": "Vorlage anzeigen"
},
"cms": {
"title": "CMS-Seiten",
"subtitle": "Rechtliche und informative Seiten verwalten",
"loadingPages": "Seiten werden geladen...",
"pages": "Seiten",
"previewLinks": "Vorschau-Links",
"englishVersion": "Englische Version",
"germanVersion": "Deutsche Version",
"editPage": "{{page}} bearbeiten",
"pageTitle": "Seitentitel",
"pageContent": "Seiteninhalt",
"pageTitlePlaceholder": "Seitentitel eingeben...",
"saveChanges": "Änderungen speichern",
"lastUpdated": "Zuletzt aktualisiert:",
"impressum": "Impressum",
"datenschutz": "Datenschutzerklärung",
"pageUpdated": "Seite erfolgreich aktualisiert"
},
"maintenance": {
"title": "Systemwartung",
"message": "Wir führen derzeit geplante Wartungsarbeiten durch, um unseren Service zu verbessern. Wir sind in Kürze wieder online.",
"expectedCompletion": "Voraussichtliche Fertigstellung:",
"checkBackLater": "Bitte schauen Sie später wieder vorbei",
"urgentMatters": "Bei dringenden Anliegen kontaktieren Sie bitte"
}
}
+262 -7
View File
@@ -22,7 +22,25 @@
"uploading": "Uploading...",
"uploaded": "Uploaded",
"photo": "photo",
"photos": "photos"
"photos": "photos",
"restore": "Restore",
"actions": "Actions",
"refresh": "Refresh",
"preview": "Preview",
"processing": "Processing...",
"upload": "Upload"
},
"upload": {
"photoCategory": "Photo Category",
"noCategory": "No category",
"eventSpecific": "(Event specific)",
"clickToUpload": "Click to upload or drag and drop",
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file)",
"selectedFiles": "Selected files",
"uploading": "Uploading...",
"uploadComplete": "Upload complete!",
"uploadFailed": "Upload failed",
"someFilesFailed": "Some files failed to upload"
},
"navigation": {
"dashboard": "Dashboard",
@@ -34,6 +52,46 @@
"emailSettings": "Email Settings",
"cmsPages": "CMS Pages"
},
"archives": {
"title": "Archives",
"subtitle": "Manage archived photo galleries",
"loadingArchives": "Loading archives...",
"totalArchives": "Total Archives",
"storageUsed": "Storage Used",
"totalPhotos": "Total Photos",
"avgArchiveSize": "Avg Archive Size",
"searchPlaceholder": "Search archives...",
"allTypes": "All Types",
"wedding": "Wedding",
"birthday": "Birthday",
"corporate": "Corporate",
"other": "Other",
"sortByDate": "Sort by Date",
"sortByName": "Sort by Name",
"sortBySize": "Sort by Size",
"tableHeaders": {
"event": "Event",
"type": "Type",
"archivedDate": "Archived Date",
"size": "Size",
"photos": "Photos",
"actions": "Actions"
},
"noArchivesFound": "No archives found",
"eventDateNA": "Event date: N/A",
"processing": "Processing...",
"download": "Download",
"restore": "Restore",
"delete": "Delete",
"showing": "Showing {{from}} to {{to}} of {{total}} archives",
"page": "Page {{current}} of {{total}}",
"storageManagement": "Storage Management",
"storageInfo": "Archives are stored permanently unless manually deleted. Consider implementing a retention policy to manage storage costs.",
"confirmRestore": "Are you sure you want to restore this archive? The event will become active again.",
"confirmDelete": "Are you sure you want to permanently delete this archive? This action cannot be undone.",
"restoreSuccess": "Archive restored successfully",
"deleteSuccess": "Archive deleted permanently"
},
"auth": {
"login": "Login",
"password": "Password",
@@ -102,12 +160,15 @@
"events": {
"title": "Events",
"createEvent": "Create Event",
"createNewEvent": "Create New Event",
"setupNewGallery": "Set up a new photo gallery for your event",
"eventDetails": "Event Details",
"eventName": "Event Name",
"eventType": "Event Type",
"eventDate": "Event Date",
"hostEmail": "Host Email",
"adminEmail": "Admin Email",
"adminNotificationEmail": "Admin Notification Email",
"expirationDate": "Expiration Date",
"active": "Active",
"archived": "Archived",
@@ -120,17 +181,75 @@
"uploadPhotos": "Upload Photos",
"archiveEvent": "Archive Event",
"archiveConfirm": "Are you sure you want to archive this event? This action cannot be undone.",
"extendExpiration": "Extend {{days}} Days"
"extendExpiration": "Extend {{days}} Days",
"backToEvents": "Back to Events",
"loadingEventDetails": "Loading event details...",
"saveChanges": "Save Changes",
"eventExpired": "This event has expired",
"eventExpiresIn": "This event expires in {{days}} days",
"guestsNoAccess": "Guests can no longer access the gallery. Consider archiving this event.",
"warningEmailsSent": "Warning emails have been sent to the host.",
"overview": "Overview",
"photos": "Photos",
"categories": "Categories",
"eventInformation": "Event Information",
"welcomeMessage": "Welcome Message",
"noWelcomeMessage": "No welcome message set",
"created": "Created",
"expires": "Expires",
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.",
"resetGalleryPassword": "Reset Gallery Password",
"photoStatistics": "Photo Statistics",
"managePhotos": "Manage Photos",
"actions": "Actions",
"archivingInfo": "Archiving will create a ZIP file of all photos and remove the gallery from public access.",
"statistics": "Statistics",
"views": "Views",
"downloads": "Downloads",
"uniqueVisitors": "Unique Visitors",
"noStatistics": "No statistics available yet",
"archiveStatus": "Archive Status",
"archivedOn": "Archived On",
"downloadArchive": "Download Archive",
"loadingPhotos": "Loading photos...",
"photoCategories": "Photo Categories",
"organizeCategoriesInfo": "Organize your photos into categories. Categories help guests navigate and find specific types of photos.",
"categoriesTip": "Tip: Categories are specific to each event. You can also create global categories in Settings.",
"contactInformation": "Contact Information",
"hostEmailHelp": "Will receive gallery creation and expiration notifications",
"adminEmailHelp": "Will receive system notifications and archive confirmations",
"securityAccess": "Security & Access",
"galleryPassword": "Gallery Password",
"confirmPassword": "Confirm Password",
"showPasswords": "Show passwords",
"gallerySettings": "Gallery Settings",
"colorTheme": "Color Theme",
"galleryExpiresIn": "Gallery Expires In",
"galleryWillExpireOn": "Gallery will expire on {{date}}",
"expirationWarning": "Guests will receive a warning email 7 days before expiration.",
"processingRequest": "Processing your request...",
"eventTypeWedding": "Wedding",
"eventTypeBirthday": "Birthday",
"eventTypeCorporate": "Corporate",
"eventTypeOther": "Other",
"days30": "30 days",
"days60": "60 days",
"days90": "90 days",
"days365": "1 year"
},
"settings": {
"title": "System Settings",
"subtitle": "Configure system-wide settings and preferences",
"loadingSettings": "Loading settings...",
"general": {
"title": "General",
"siteConfiguration": "Site Configuration",
"siteUrl": "Site URL",
"siteUrlHelp": "Used for generating gallery links in emails",
"defaultExpiration": "Default Expiration (days)",
"defaultExpirationHelp": "How long galleries remain active by default",
"maxFileSize": "Max File Size (MB)",
"maxFileSizeHelp": "Maximum size per uploaded photo",
"allowedFileTypes": "Allowed File Types",
"allowedFileTypesHelp": "Comma-separated list of file extensions",
"featureToggles": "Feature Toggles",
@@ -139,7 +258,10 @@
"enableRegistration": "Allow self-registration for admins",
"maintenanceMode": "Enable maintenance mode",
"language": "Language",
"saveSettings": "Save General Settings"
"defaultLanguage": "Default Language",
"defaultLanguageHelp": "Language shown to guests before login",
"saveSettings": "Save General Settings",
"saveGeneralSettings": "Save General Settings"
},
"storage": {
"title": "Storage",
@@ -150,23 +272,31 @@
"storageUsage": "Storage Usage",
"storageByEvent": "Storage by Event",
"storageManagement": "Storage Management",
"storageManagementHelp": "Consider archiving or deleting old events to free up storage space. Archived events are compressed and use less storage than active galleries."
"storageManagementHelp": "Consider archiving or deleting old events to free up storage space. Archived events are compressed and use less storage than active galleries.",
"noEventsUsingStorage": "No events using storage",
"unlimited": "Unlimited"
},
"security": {
"title": "Security",
"passwordSettings": "Password Settings",
"requirePassword": "Require password for all galleries",
"minPasswordLength": "Minimum Password Length",
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
"sessionAuth": "Session & Authentication",
"sessionTimeout": "Session Timeout (minutes)",
"sessionTimeoutHelp": "Admin session timeout in minutes",
"maxLoginAttempts": "Max Login Attempts",
"maxLoginAttemptsHelp": "Maximum failed login attempts before lockout",
"enable2FA": "Enable two-factor authentication for admins",
"recaptchaSettings": "reCAPTCHA Settings",
"enableRecaptcha": "Enable reCAPTCHA for login forms",
"siteKey": "Site Key",
"siteKeyHelp": "Your reCAPTCHA v2 site key (public)",
"secretKey": "Secret Key",
"secretKeyHelp": "Your reCAPTCHA v2 secret key (keep private)",
"recaptchaHelp": "Get your reCAPTCHA keys from",
"saveSettings": "Save Security Settings"
"saveSettings": "Save Security Settings",
"saveSecuritySettings": "Save Security Settings"
},
"categories": {
"title": "Categories",
@@ -174,33 +304,98 @@
"aboutText": "Global categories are available for all events. You can also create event-specific categories when editing individual events. Categories help organize photos and allow guests to filter photos by type in the gallery view."
}
},
"analytics": {
"title": "Analytics Dashboard",
"titleSimple": "Analytics",
"subtitle": "Track gallery performance and visitor engagement",
"detailedSubtitle": "Detailed analytics powered by Umami",
"loadingAnalytics": "Loading analytics...",
"showSummaryView": "Show Summary View",
"fullDashboard": "Full Dashboard",
"refresh": "Refresh",
"last7Days": "Last 7 days",
"last30Days": "Last 30 days",
"last90Days": "Last 90 days",
"pageViews": "Page Views",
"uniqueVisitors": "Unique Visitors",
"totalDownloads": "Total Downloads",
"topGallery": "Top Gallery",
"topPages": "Top Pages",
"views": "views",
"visitors": "unique visitors",
"topDownloadsByGallery": "Top Downloads by Gallery",
"deviceBreakdown": "Device Breakdown",
"desktop": "Desktop",
"mobile": "Mobile",
"tablet": "Tablet",
"storageUsage": "Storage Usage",
"used": "Used",
"of": "of",
"totalPhotos": "Total Photos",
"activeEvents": "Active Events",
"notConfigured": "Umami Analytics Not Configured",
"configureInstructions": "To see real analytics data, configure Umami in your environment variables and admin panel settings.",
"noData": "No data available",
"percentChange": "{{percent}}% from last period"
},
"branding": {
"title": "Branding & Customization",
"title": "Branding & Themes",
"titleFull": "Branding & Customization",
"subtitle": "Customize the look and feel of your galleries",
"loadingBranding": "Loading branding settings...",
"companyInfo": "Company Information",
"companyName": "Company Name",
"companyNameHelp": "Displayed in gallery headers and emails",
"companyTagline": "Company Tagline",
"companyTaglineHelp": "A short description of your business",
"supportEmail": "Support Email",
"supportEmailHelp": "Contact email for guest support",
"footerText": "Footer Text",
"footerTextHelp": "Displayed at the bottom of galleries",
"logo": "Logo",
"currentLogo": "Current logo",
"uploadLogo": "Upload Logo",
"removeLogo": "Remove Logo",
"logoHelp": "Recommended size: 200x60px, PNG or JPEG",
"favicon": "Favicon",
"currentFavicon": "Current favicon",
"uploadFavicon": "Upload Favicon",
"removeFavicon": "Remove Favicon",
"faviconHelp": "PNG or ICO format, recommended size: 32x32px",
"watermark": "Watermark",
"watermarkSettings": "Watermark Settings",
"enableWatermark": "Enable watermark on photos",
"enableWatermarks": "Enable Watermarks",
"watermarkHelp": "Add your company name as a watermark on downloaded photos",
"watermarkLogo": "Watermark Logo",
"currentWatermark": "Current watermark",
"uploadWatermarkLogo": "Upload Watermark Logo",
"watermarkPosition": "Watermark Position",
"topLeft": "Top Left",
"topRight": "Top Right",
"center": "Center",
"bottomLeft": "Bottom Left",
"bottomRight": "Bottom Right",
"watermarkOpacity": "Watermark Opacity",
"watermarkSize": "Watermark Size",
"theme": "Theme",
"galleryTheme": "Gallery Theme",
"themeCustomization": "Theme Customization",
"selectPreset": "Select a preset theme",
"colors": "Colors",
"primaryColor": "Primary Color",
"secondaryColor": "Secondary Color",
"accentColor": "Accent Color",
"backgroundColor": "Background Color",
"textColor": "Text Color",
"customCSS": "Custom CSS",
"preview": "Preview",
"previewInNewTab": "Preview in New Tab",
"reset": "Reset",
"saveChanges": "Save Changes"
"saveChanges": "Save Changes",
"applyLivePreview": "Apply changes immediately (Live Preview)",
"eventSpecificThemes": "Event-Specific Themes",
"eventThemesInfo": "You can override these global theme settings for individual events when creating or editing them."
},
"admin": {
"title": "Admin Panel",
@@ -214,6 +409,10 @@
"storagePercent": "{{percent}}% of {{limit}}",
"notifications": "Notifications",
"viewAllNotifications": "View all notifications",
"noNotifications": "No new notifications",
"markAsRead": "Mark as read",
"markAllAsRead": "Mark all as read",
"notificationSettings": "Notification Settings",
"changePassword": "Change Password",
"loadingDashboard": "Loading dashboard...",
"activeEvents": "Active Events",
@@ -238,6 +437,8 @@
"notFound": "Not Found",
"galleryNotFound": "Gallery Not Found",
"galleryNotFoundMessage": "This gallery does not exist or has been removed.",
"galleryArchived": "Gallery Archived",
"galleryArchivedMessage": "This gallery has been archived and is no longer accessible. Please contact the event organizer if you need access to these photos.",
"unauthorized": "Unauthorized",
"forbidden": "Forbidden",
"serverError": "Server Error",
@@ -280,5 +481,59 @@
"pageUpdated": "Page updated successfully",
"archiveRestored": "Archive restored successfully",
"archiveDeleted": "Archive deleted permanently"
},
"email": {
"title": "Email Configuration",
"subtitle": "Configure email settings for notifications",
"loadingSettings": "Loading email settings...",
"smtpConfiguration": "SMTP Configuration",
"smtpHost": "SMTP Host",
"smtpHostHelp": "Your email server hostname",
"smtpPort": "SMTP Port",
"smtpPortHelp": "Usually 587 for TLS, 465 for SSL, 25 for unencrypted",
"smtpSecure": "Use SSL/TLS",
"smtpSecureHelp": "Enable for secure email transmission",
"smtpUsername": "SMTP Username",
"smtpUsernameHelp": "Your email account username",
"smtpPassword": "SMTP Password",
"smtpPasswordHelp": "Your email account password",
"fromDetails": "From Details",
"fromEmail": "From Email",
"fromEmailHelp": "Email address that appears as sender",
"fromName": "From Name",
"fromNameHelp": "Name that appears as sender",
"testConfiguration": "Test Configuration",
"testEmail": "Test Email Address",
"testEmailHelp": "Send a test email to verify settings",
"sendTestEmail": "Send Test Email",
"saveConfiguration": "Save Configuration",
"emailTemplates": "Email Templates",
"templateVariables": "Available Variables",
"previewTemplate": "Preview Template"
},
"cms": {
"title": "CMS Pages",
"subtitle": "Manage legal and informational pages",
"loadingPages": "Loading pages...",
"pages": "Pages",
"previewLinks": "Preview Links",
"englishVersion": "English Version",
"germanVersion": "German Version",
"editPage": "Edit {{page}}",
"pageTitle": "Page Title",
"pageContent": "Page Content",
"pageTitlePlaceholder": "Enter page title...",
"saveChanges": "Save Changes",
"lastUpdated": "Last updated:",
"impressum": "Legal Notice",
"datenschutz": "Privacy Policy",
"pageUpdated": "Page updated successfully"
},
"maintenance": {
"title": "System Maintenance",
"message": "We're currently performing scheduled maintenance to improve our service. We'll be back online shortly.",
"expectedCompletion": "Expected completion time:",
"checkBackLater": "Please check back later",
"urgentMatters": "For urgent matters, please contact"
}
}
+32 -20
View File
@@ -5,7 +5,7 @@ import { format, differenceInDays, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, Input, Button, Loading } from '../components/common';
import { Card, CardContent, Input, Button, Loading, ReCaptcha } from '../components/common';
import { useGalleryAuth } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery';
@@ -19,6 +19,7 @@ export const GalleryPage: React.FC = () => {
const [password, setPassword] = useState('');
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
// Fetch gallery info (public data)
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
@@ -55,7 +56,7 @@ export const GalleryPage: React.FC = () => {
try {
setIsLoggingIn(true);
setLoginError(null);
await login(slug!, password);
await login(slug!, password, recaptchaToken);
// Track successful password entry
analyticsService.trackGalleryEvent('password_entry', {
@@ -78,7 +79,7 @@ export const GalleryPage: React.FC = () => {
// Show loading state
if (isLoadingInfo) {
return (
<div className="min-h-screen bg-neutral-50">
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex items-center justify-center">
<Loading size="lg" text={t('gallery.loading')} />
</div>
@@ -88,14 +89,18 @@ export const GalleryPage: React.FC = () => {
// Show error state
if (infoError) {
// Check if it's an archived gallery error
const errorMessage = (infoError as any)?.response?.data?.error;
const isArchived = errorMessage?.includes('archived');
return (
<div className="min-h-screen bg-neutral-50">
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex flex-col">
{/* Logo at top */}
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={settingsData.branding_logo_url}
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
@@ -106,9 +111,11 @@ export const GalleryPage: React.FC = () => {
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">{t('errors.galleryNotFound')}</h2>
<h2 className="text-xl font-semibold mb-2">
{t(isArchived ? 'errors.galleryArchived' : 'errors.galleryNotFound')}
</h2>
<p className="text-neutral-600">
{t('errors.galleryNotFoundMessage')}
{t(isArchived ? 'errors.galleryArchivedMessage' : 'errors.galleryNotFoundMessage')}
</p>
</CardContent>
</Card>
@@ -140,13 +147,13 @@ export const GalleryPage: React.FC = () => {
// Show expired state
if (galleryInfo?.is_expired) {
return (
<div className="min-h-screen bg-neutral-50">
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex flex-col">
{/* Logo at top */}
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={settingsData.branding_logo_url}
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
@@ -198,26 +205,26 @@ export const GalleryPage: React.FC = () => {
// Show login form
return (
<div className="min-h-screen bg-gradient-to-br from-neutral-50 to-sand-100">
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
{settingsData?.branding_logo_url ? (
<img
src={settingsData.branding_logo_url}
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-20 w-auto object-contain mx-auto mb-4"
/>
) : (
<div className="inline-flex items-center justify-center w-20 h-20 bg-primary-600 rounded-2xl mb-4">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl mb-4" style={{ backgroundColor: 'var(--color-primary, #5C8762)' }}>
<Camera className="w-10 h-10 text-white" />
</div>
)}
<h1 className="text-3xl font-bold text-neutral-900 mb-2">
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--color-text, #171717)' }}>
{galleryInfo?.event_name}
</h1>
<div className="flex items-center justify-center text-neutral-600 text-sm">
<div className="flex items-center justify-center text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
<Calendar className="w-4 h-4 mr-1" />
{format(parseISO(galleryInfo!.event_date), 'MMMM d, yyyy')}
</div>
@@ -256,6 +263,11 @@ export const GalleryPage: React.FC = () => {
autoFocus
/>
<ReCaptcha
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
/>
<Button
type="submit"
variant="primary"
@@ -277,19 +289,19 @@ export const GalleryPage: React.FC = () => {
{/* Legal Links */}
<div className="text-center mt-6">
<div className="flex items-center justify-center gap-4">
<a
href="/impressum"
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</a>
</Link>
<span className="text-xs text-neutral-400">|</span>
<a
href="/datenschutz"
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</a>
</Link>
</div>
</div>
</div>
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import { MaintenanceMode } from '../components/MaintenanceMode';
export const MaintenancePage: React.FC = () => {
return <MaintenanceMode />;
};
+55 -14
View File
@@ -1,15 +1,17 @@
import React, { useState } from 'react';
import { Navigate } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { Navigate, useSearchParams } from 'react-router-dom';
import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useQuery } from '@tanstack/react-query';
import { Button, Input, Card } from '../../components/common';
import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { useAdminAuth } from '../../contexts';
import { authService } from '../../services/auth.service';
import { getAuthToken } from '../../config/api';
import { getAuthToken, api } from '../../config/api';
export const AdminLoginPage: React.FC = () => {
const { isAuthenticated, login } = useAdminAuth();
const [searchParams] = useSearchParams();
const [formData, setFormData] = useState({
email: '',
@@ -19,6 +21,24 @@ export const AdminLoginPage: React.FC = () => {
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [loginSuccess, setLoginSuccess] = useState(false);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
// Fetch branding settings
const { data: settingsData } = useQuery({
queryKey: ['admin-login-settings'],
queryFn: async () => {
const response = await api.get('/api/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Check for session expired message
useEffect(() => {
if (searchParams.get('session') === 'expired') {
toast.info('Your session has expired. Please log in again.');
}
}, [searchParams]);
// Redirect if already authenticated or login successful
if (isAuthenticated || loginSuccess) {
@@ -55,7 +75,10 @@ export const AdminLoginPage: React.FC = () => {
setErrors({});
try {
const response = await authService.adminLogin(formData);
const response = await authService.adminLogin({
...formData,
recaptchaToken
});
login(response.token, response.user);
toast.success('Login successful!');
setLoginSuccess(true);
@@ -93,15 +116,23 @@ export const AdminLoginPage: React.FC = () => {
};
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-neutral-100 flex items-center justify-center p-4">
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary-600 rounded-full mb-4">
<Lock className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-neutral-900">Admin Login</h1>
<p className="text-neutral-600 mt-2">Sign in to manage your photo galleries</p>
{settingsData?.branding_logo_url ? (
<img
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto mb-4"
/>
) : (
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" style={{ backgroundColor: 'var(--color-primary, #5C8762)' }}>
<Lock className="w-8 h-8 text-white" />
</div>
)}
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>Admin Login</h1>
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>Sign in to manage your photo galleries</p>
</div>
{/* Login Form */}
@@ -178,6 +209,12 @@ export const AdminLoginPage: React.FC = () => {
</a>
</div>
{/* reCAPTCHA */}
<ReCaptcha
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
/>
{/* Submit Button */}
<Button
type="submit"
@@ -192,10 +229,14 @@ export const AdminLoginPage: React.FC = () => {
</Card>
{/* Footer */}
<p className="text-center text-sm text-neutral-600 mt-8">
<p className="text-center text-sm mt-8" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
Need help? Contact{' '}
<a href="mailto:support@example.com" className="text-primary-600 hover:text-primary-700">
support@example.com
<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>
+26 -11
View File
@@ -9,26 +9,36 @@ import {
AlertCircle,
RotateCcw,
Trash2,
Eye,
ChevronLeft,
ChevronRight
} from 'lucide-react';
import { format, parseISO } from 'date-fns';
import { format, parseISO, isValid } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { archiveService } from '../../services/archive.service';
import { useNavigate } from 'react-router-dom';
// import { useNavigate } from 'react-router-dom';
export const ArchivesPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState<string>('all');
const [sortBy, setSortBy] = useState<'date' | 'size' | 'name'>('date');
const [currentPage, setCurrentPage] = useState(1);
const navigate = useNavigate();
// const navigate = useNavigate();
const queryClient = useQueryClient();
// Helper function to safely format dates
const formatDate = (dateString: string | null | undefined, formatStr: string): string => {
if (!dateString) return '';
try {
const date = parseISO(dateString);
return isValid(date) ? format(date, formatStr) : '';
} catch {
return '';
}
};
// Fetch archives from API
const { data: archivesData, isLoading } = useQuery({
queryKey: ['admin-archives', currentPage],
@@ -54,7 +64,9 @@ export const ArchivesPage: React.FC = () => {
return b.archiveSize - a.archiveSize;
case 'date':
default:
return new Date(b.archivedAt).getTime() - new Date(a.archivedAt).getTime();
const dateA = a.archivedAt ? new Date(a.archivedAt).getTime() : 0;
const dateB = b.archivedAt ? new Date(b.archivedAt).getTime() : 0;
return dateB - dateA;
}
});
@@ -107,9 +119,10 @@ export const ArchivesPage: React.FC = () => {
}
};
const handleViewDetails = (archive: typeof archives[0]) => {
navigate(`/admin/archives/${archive.id}`);
};
// Details view not implemented yet
// const handleViewDetails = (archive: typeof archives[0]) => {
// navigate(`/admin/archives/${archive.id}`);
// };
if (isLoading) {
return (
@@ -257,7 +270,7 @@ export const ArchivesPage: React.FC = () => {
<div>
<p className="text-sm font-medium text-neutral-900">{archive.eventName}</p>
<p className="text-xs text-neutral-500">
Event date: {format(parseISO(archive.eventDate), 'MMM d, yyyy')}
Event date: {formatDate(archive.eventDate, 'MMM d, yyyy') || 'N/A'}
</p>
</div>
</td>
@@ -266,9 +279,9 @@ export const ArchivesPage: React.FC = () => {
</td>
<td className="px-6 py-4 text-sm text-neutral-700">
<div>
<p>{format(parseISO(archive.archivedAt), 'MMM d, yyyy')}</p>
<p>{formatDate(archive.archivedAt, 'MMM d, yyyy') || 'Processing...'}</p>
<p className="text-xs text-neutral-500">
{format(parseISO(archive.archivedAt), 'h:mm a')}
{formatDate(archive.archivedAt, 'h:mm a')}
</p>
</div>
</td>
@@ -280,6 +293,7 @@ export const ArchivesPage: React.FC = () => {
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{/* Details view not implemented yet
<Button
variant="ghost"
size="sm"
@@ -288,6 +302,7 @@ export const ArchivesPage: React.FC = () => {
>
Details
</Button>
*/}
<Button
variant="ghost"
size="sm"
+64 -9
View File
@@ -14,6 +14,7 @@ import {
import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { emailService, type EmailConfig, type EmailTemplate } from '../../services/email.service';
@@ -87,6 +88,12 @@ export const EmailConfigPage: React.FC = () => {
const [editedTemplate, setEditedTemplate] = useState<Partial<EmailTemplate>>({});
const [showPassword, setShowPassword] = useState(false);
const [testEmail, setTestEmail] = useState('');
const [showPreview, setShowPreview] = useState(false);
const [previewData, setPreviewData] = useState<{ subject: string; htmlContent: string; textContent?: string }>({
subject: '',
htmlContent: '',
textContent: ''
});
const queryClient = useQueryClient();
// SMTP Configuration state
@@ -205,6 +212,35 @@ export const EmailConfigPage: React.FC = () => {
}
};
const handlePreviewTemplate = async () => {
if (!selectedTemplateKey || !editedTemplate) return;
// Generate sample data based on the template
const sampleData: Record<string, string> = {
event_name: 'John & Jane Wedding',
event_date: 'December 25, 2024',
password: 'wedding2024',
gallery_link: 'https://photos.example.com/gallery/john-jane-wedding',
expiration_date: 'January 25, 2025',
welcome_message: 'Thank you for celebrating our special day with us!',
days_remaining: '30',
admin_email: 'admin@example.com',
host_email: 'host@example.com'
};
try {
const preview = await emailService.previewTemplate(selectedTemplateKey, sampleData);
setPreviewData({
subject: preview.subject,
htmlContent: preview.body_html,
textContent: preview.body_text
});
setShowPreview(true);
} catch (error) {
toast.error('Failed to preview template');
}
};
const renderVariableHelp = () => {
const variables = editedTemplate.variables || [];
return (
@@ -479,15 +515,25 @@ export const EmailConfigPage: React.FC = () => {
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900">Edit Template</h3>
<Button
variant="primary"
size="sm"
onClick={handleSaveTemplate}
isLoading={saveTemplateMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
Save Changes
</Button>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handlePreviewTemplate}
leftIcon={<Eye className="w-4 h-4" />}
>
Preview
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSaveTemplate}
isLoading={saveTemplateMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
Save Changes
</Button>
</div>
</div>
<div className="space-y-4">
@@ -533,6 +579,15 @@ export const EmailConfigPage: React.FC = () => {
</div>
</div>
)}
{/* Email Preview Modal */}
<EmailPreviewModal
isOpen={showPreview}
onClose={() => setShowPreview(false)}
subject={previewData.subject}
htmlContent={previewData.htmlContent}
textContent={previewData.textContent}
/>
</div>
);
};
+244 -39
View File
@@ -14,16 +14,20 @@ import {
AlertTriangle,
Copy,
CheckCircle,
Upload
Upload,
Image,
Key
} from 'lucide-react';
import { format, parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { PhotoUpload, EventCategoryManager } from '../../components/admin';
import { PhotoUpload, EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { galleryService } from '../../services/gallery.service';
import { archiveService } from '../../services/archive.service';
import { photosService, AdminPhoto } from '../../services/photos.service';
export const EventDetailsPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -45,6 +49,17 @@ export const EventDetailsPage: React.FC = () => {
});
const [copiedLink, setCopiedLink] = useState(false);
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
const [showPasswordReset, setShowPasswordReset] = useState(false);
// Photo filters state
const [photoFilters, setPhotoFilters] = useState({
category_id: undefined as number | null | undefined,
search: '',
sort: 'date' as 'date' | 'name' | 'size',
order: 'desc' as 'asc' | 'desc'
});
// Fetch event details
const { data: event, isLoading: eventLoading } = useQuery({
@@ -61,6 +76,23 @@ export const EventDetailsPage: React.FC = () => {
retry: false,
});
// Fetch photos when on photos tab
const { data: photos = [], isLoading: photosLoading, refetch: refetchPhotos } = useQuery({
queryKey: ['admin-event-photos', id, photoFilters],
queryFn: () => photosService.getEventPhotos(parseInt(id!), photoFilters),
enabled: !!id && activeTab === 'photos',
});
// Fetch categories for the event
const { data: categories = [] } = useQuery({
queryKey: ['admin-event-categories', id],
queryFn: async () => {
const response = await eventsService.getEventCategories(parseInt(id!));
return response || [];
},
enabled: !!id,
});
// Update mutation
const updateMutation = useMutation({
mutationFn: (data: any) => eventsService.updateEvent(parseInt(id!), data),
@@ -257,8 +289,51 @@ export const EventDetailsPage: React.FC = () => {
</Card>
)}
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Tabs */}
<div className="mb-6 border-b border-neutral-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('overview')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'overview'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
Overview
</button>
<button
onClick={() => setActiveTab('photos')}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === 'photos'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
<Image className="w-4 h-4" />
Photos
{event.photo_count && event.photo_count > 0 && (
<span className="ml-1 px-2 py-0.5 text-xs font-medium bg-neutral-100 text-neutral-700 rounded-full">
{event.photo_count}
</span>
)}
</button>
<button
onClick={() => setActiveTab('categories')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'categories'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
Categories
</button>
</nav>
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Details */}
<div className="lg:col-span-2 space-y-6">
{/* Event Information */}
@@ -361,34 +436,25 @@ export const EventDetailsPage: React.FC = () => {
<p className="text-sm text-neutral-600 mt-2">
Share this link with guests. They'll need the password to access the gallery.
</p>
</Card>
{/* Photo Management */}
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">Photo Management</h2>
<Button
variant="primary"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowPhotoUpload(!showPhotoUpload)}
>
Upload Photos
</Button>
</div>
{showPhotoUpload && (
<div className="mb-4">
<PhotoUpload
eventId={parseInt(id!)}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
toast.success('Photos uploaded successfully');
setShowPhotoUpload(false);
}}
/>
{!event.is_archived && (
<div className="mt-4 pt-4 border-t border-neutral-200">
<Button
variant="outline"
size="sm"
leftIcon={<Key className="w-4 h-4" />}
onClick={() => setShowPasswordReset(true)}
className="w-full justify-center"
>
Reset Gallery Password
</Button>
</div>
)}
</Card>
{/* Photo Statistics */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">Photo Statistics</h2>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
@@ -403,18 +469,22 @@ export const EventDetailsPage: React.FC = () => {
</span>
</div>
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Storage Location:</strong> /storage/events/active/{event.slug}/
</p>
<p className="text-xs text-blue-600 mt-1">
Photos are organized by categories you define.
</p>
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">Categories</span>
<span className="text-sm font-medium">{categories.length}</span>
</div>
</div>
<div className="mt-6 pt-4 border-t border-neutral-200">
<EventCategoryManager eventId={parseInt(id!)} />
<div className="mt-4">
<Button
variant="outline"
size="sm"
leftIcon={<Image className="w-4 h-4" />}
onClick={() => setActiveTab('photos')}
className="w-full justify-center"
>
Manage Photos
</Button>
</div>
</Card>
@@ -503,7 +573,15 @@ export const EventDetailsPage: React.FC = () => {
variant="outline"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={() => toast.info('Archive download coming soon')}
onClick={async () => {
try {
toast.info(`Downloading ${event.event_name} archive...`);
await archiveService.downloadArchive(Number(id), `${event.slug}-archive.zip`);
toast.success('Download started');
} catch (error) {
toast.error('Failed to download archive');
}
}}
className="w-full justify-center"
>
Download Archive
@@ -514,6 +592,133 @@ export const EventDetailsPage: React.FC = () => {
)}
</div>
</div>
)}
{/* Photos Tab */}
{activeTab === 'photos' && (
<div>
{/* Photo Upload */}
{showPhotoUpload && (
<Card padding="md" className="mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">Upload Photos</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setShowPhotoUpload(false)}
>
<X className="w-4 h-4" />
</Button>
</div>
<PhotoUpload
eventId={parseInt(id!)}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
queryClient.invalidateQueries({ queryKey: ['admin-event-photos', id] });
toast.success('Photos uploaded successfully');
setShowPhotoUpload(false);
refetchPhotos();
}}
/>
</Card>
)}
{/* Photo Filters */}
<PhotoFilters
categories={categories}
selectedCategory={photoFilters.category_id}
searchTerm={photoFilters.search}
sortBy={photoFilters.sort}
sortOrder={photoFilters.order}
onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))}
onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))}
onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))}
/>
{/* Actions Bar */}
{!showPhotoUpload && (
<div className="mb-4 flex justify-between items-center">
<Button
variant="primary"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowPhotoUpload(true)}
>
Upload Photos
</Button>
</div>
)}
{/* Photo Grid */}
{photosLoading ? (
<div className="flex items-center justify-center py-12">
<Loading size="lg" text="Loading photos..." />
</div>
) : (
<AdminPhotoGrid
photos={photos}
eventId={parseInt(id!)}
onPhotoClick={(photo, index) => setSelectedPhoto({ photo, index })}
onPhotosDeleted={() => {
refetchPhotos();
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
}}
/>
)}
{/* Photo Viewer */}
{selectedPhoto && (
<AdminPhotoViewer
photos={photos}
initialIndex={selectedPhoto.index}
eventId={parseInt(id!)}
onClose={() => setSelectedPhoto(null)}
onPhotoDeleted={() => {
refetchPhotos();
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
setSelectedPhoto(null);
}}
categories={categories}
/>
)}
</div>
)}
{/* Categories Tab */}
{activeTab === 'categories' && (
<div>
<Card padding="md">
<div className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-2">Photo Categories</h2>
<p className="text-sm text-neutral-600">
Organize your photos into categories. Categories help guests navigate and find specific types of photos.
</p>
</div>
<EventCategoryManager
eventId={parseInt(id!)}
/>
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Tip:</strong> Categories are specific to each event. You can create custom categories like "Ceremony", "Reception", "Portraits", etc.
</p>
</div>
</Card>
</div>
)}
{/* Password Reset Modal */}
{showPasswordReset && (
<PasswordResetModal
eventName={event.event_name}
onConfirm={async (sendEmail) => {
const result = await eventsService.resetPassword(event.id, sendEmail);
return result;
}}
onClose={() => setShowPasswordReset(false)}
/>
)}
</div>
);
};
+31 -4
View File
@@ -15,6 +15,7 @@ import { format, parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common';
import { BulkArchiveModal } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import type { Event } from '../../types';
@@ -28,6 +29,7 @@ export const EventsListPage: React.FC = () => {
const [selectedEvents, setSelectedEvents] = useState<number[]>([]);
// const [showFilters, setShowFilters] = useState(false);
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
// Get filter from URL
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | null;
@@ -63,6 +65,25 @@ export const EventsListPage: React.FC = () => {
},
});
// Bulk archive mutation
const bulkArchiveMutation = useMutation({
mutationFn: eventsService.bulkArchiveEvents,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
setSelectedEvents([]);
setShowBulkArchiveModal(false);
if (data.results.failed.length === 0) {
toast.success(`Successfully archived ${data.results.successful.length} events`);
} else {
toast.warning(`Archived ${data.results.successful.length} events, ${data.results.failed.length} failed`);
}
},
onError: () => {
toast.error('Failed to archive events');
},
});
// Filter and search events
const filteredEvents = useMemo(() => {
if (!data?.events) return [];
@@ -237,10 +258,7 @@ export const EventsListPage: React.FC = () => {
<Button
variant="outline"
size="sm"
onClick={() => {
// Handle bulk archive
toast.info('Bulk archive coming soon');
}}
onClick={() => setShowBulkArchiveModal(true)}
>
Archive Selected
</Button>
@@ -407,6 +425,15 @@ export const EventsListPage: React.FC = () => {
</table>
</div>
</Card>
{/* Bulk Archive Modal */}
<BulkArchiveModal
isOpen={showBulkArchiveModal}
onClose={() => setShowBulkArchiveModal(false)}
onConfirm={() => bulkArchiveMutation.mutate(selectedEvents)}
selectedEvents={filteredEvents.filter(e => selectedEvents.includes(e.id))}
isLoading={bulkArchiveMutation.isPending}
/>
</div>
</ErrorBoundary>
);
+6 -1
View File
@@ -18,7 +18,7 @@ import { useTranslation } from 'react-i18next';
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'general' | 'storage' | 'security' | 'categories'>('general');
const queryClient = useQueryClient();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
// Fetch settings
const { data: settings, isLoading } = useQuery({
@@ -60,6 +60,11 @@ export const SettingsPage: React.FC = () => {
React.useEffect(() => {
if (settings) {
// Set the language if it's different from current
if (settings.general_default_language && settings.general_default_language !== i18n.language) {
i18n.changeLanguage(settings.general_default_language);
}
// Extract general settings
setGeneralSettings({
site_url: settings.general_site_url || '',
+7 -2
View File
@@ -65,10 +65,15 @@ class AnalyticsService {
this.initialized = true;
}
// Check if analytics is initialized
isInitialized() {
return this.initialized;
}
// Track custom events
track(eventName: string, eventData?: Record<string, any>) {
if (!this.initialized || !window.umami) {
console.warn('Umami Analytics not initialized');
// Silently ignore if not initialized
return;
}
@@ -79,7 +84,7 @@ class AnalyticsService {
// Track page views manually
trackPageView(url?: string, referrer?: string) {
if (!this.initialized || !window.umami) {
console.warn('Umami Analytics not initialized');
// Silently ignore if not initialized
return;
}
+5 -3
View File
@@ -3,11 +3,12 @@ import type { LoginResponse, GalleryAuthResponse } from '../types';
export const authService = {
// Admin authentication
async adminLogin(credentials: { email: string; password: string }): Promise<LoginResponse> {
async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise<LoginResponse> {
// Backend expects 'username' field, but we accept email
const response = await api.post<LoginResponse>('/api/auth/admin/login', {
username: credentials.email,
password: credentials.password
password: credentials.password,
recaptchaToken: credentials.recaptchaToken
});
setAuthToken(response.data.token, true);
@@ -20,10 +21,11 @@ export const authService = {
},
// Gallery authentication
async verifyGalleryPassword(slug: string, password: string): Promise<GalleryAuthResponse> {
async verifyGalleryPassword(slug: string, password: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
const response = await api.post<GalleryAuthResponse>('/api/auth/gallery/verify', {
slug,
password,
recaptchaToken
});
setAuthToken(response.data.token, false);
+26
View File
@@ -80,6 +80,20 @@ export const eventsService = {
await api.post(`/api/admin/events/${id}/archive`);
},
// Bulk archive events (admin)
async bulkArchiveEvents(eventIds: number[]): Promise<{
message: string;
results: {
successful: Array<{ id: number; name: string }>;
failed: Array<{ id: number; name: string; error: string }>;
};
}> {
const response = await api.post('/api/admin/events/bulk-archive', {
eventIds,
});
return response.data;
},
// Extend event expiration (admin)
async extendExpiration(id: number, days: number): Promise<Event> {
const response = await api.post<Event>(`/api/events/${id}/extend`, {
@@ -87,4 +101,16 @@ export const eventsService = {
});
return response.data;
},
// Get event categories
async getEventCategories(eventId: number): Promise<Array<{ id: number; name: string; slug: string }>> {
const response = await api.get(`/api/admin/categories/event/${eventId}`);
return response.data || [];
},
// Reset event password
async resetPassword(eventId: number, sendEmail: boolean = true): Promise<{ message: string; newPassword: string; emailSent: boolean }> {
const response = await api.post(`/api/admin/events/${eventId}/reset-password`, { sendEmail });
return response.data;
},
};
+2 -1
View File
@@ -6,4 +6,5 @@ export { analyticsService } from './analytics.service';
export { archiveService } from './archive.service';
export { emailService } from './email.service';
export { settingsService } from './settings.service';
export { cmsService } from './cms.service';
export { cmsService } from './cms.service';
export { notificationsService } from './notifications.service';
@@ -0,0 +1,101 @@
import { api } from '../config/api';
export interface Notification {
id: number;
type: string;
actorType: string;
actorName: string;
eventName?: string;
eventId?: number;
metadata: Record<string, any>;
createdAt: string;
readAt?: string;
isRead: boolean;
}
export interface NotificationsResponse {
notifications: Notification[];
unreadCount: number;
}
export const notificationsService = {
// Get notifications
async getNotifications(includeRead: boolean = false, limit: number = 20): Promise<NotificationsResponse> {
const response = await api.get('/api/admin/notifications', {
params: { includeRead, limit }
});
return response.data;
},
// Mark single notification as read
async markAsRead(notificationId: number): Promise<void> {
await api.put(`/api/admin/notifications/${notificationId}/read`);
},
// Mark all notifications as read
async markAllAsRead(): Promise<void> {
await api.put('/api/admin/notifications/read-all');
},
// Clear old notifications
async clearOldNotifications(): Promise<{ deletedCount: number }> {
const response = await api.delete('/api/admin/notifications/clear-old');
return response.data;
},
// Format notification message
formatNotificationMessage(notification: Notification): string {
switch (notification.type) {
case 'event_created':
return `New event "${notification.eventName}" was created`;
case 'event_archived':
return `Event "${notification.eventName}" was archived`;
case 'photos_uploaded':
return `${notification.metadata.count || 0} photos uploaded to "${notification.eventName}"`;
case 'event_expiring':
return `Event "${notification.eventName}" expires in ${notification.metadata.days || 0} days`;
case 'event_expired':
return `Event "${notification.eventName}" has expired`;
case 'password_changed':
return `Password changed by ${notification.actorName}`;
case 'settings_updated':
return `${notification.metadata.type || 'System'} settings updated`;
case 'email_template_updated':
return `Email template "${notification.metadata.template}" updated`;
case 'bulk_download':
return `${notification.metadata.count || 0} photos downloaded from "${notification.eventName}"`;
case 'storage_warning':
return `Storage usage at ${notification.metadata.percentage || 0}%`;
default:
return notification.metadata.message || 'System notification';
}
},
// Get notification icon and color
getNotificationStyle(type: string): { icon: string; color: string } {
switch (type) {
case 'event_created':
return { icon: 'Calendar', color: 'text-blue-600' };
case 'event_archived':
return { icon: 'Archive', color: 'text-green-600' };
case 'photos_uploaded':
return { icon: 'Image', color: 'text-purple-600' };
case 'event_expiring':
return { icon: 'AlertCircle', color: 'text-amber-600' };
case 'event_expired':
return { icon: 'Clock', color: 'text-red-600' };
case 'password_changed':
return { icon: 'Lock', color: 'text-indigo-600' };
case 'settings_updated':
return { icon: 'Settings', color: 'text-gray-600' };
case 'email_template_updated':
return { icon: 'Mail', color: 'text-teal-600' };
case 'bulk_download':
return { icon: 'Download', color: 'text-cyan-600' };
case 'storage_warning':
return { icon: 'Database', color: 'text-orange-600' };
default:
return { icon: 'Bell', color: 'text-gray-600' };
}
}
};
+93
View File
@@ -0,0 +1,93 @@
import { api } from '../config/api';
export interface AdminPhoto {
id: number;
filename: string;
path: string;
url: string;
thumbnail_url: string | null;
type: string;
category_id: number | null;
category_name: string | null;
category_slug: string | null;
size: number;
uploaded_at: string;
view_count?: number;
download_count?: number;
}
export interface PhotoFilters {
category_id?: number | null;
type?: string;
search?: string;
sort?: 'date' | 'name' | 'size';
order?: 'asc' | 'desc';
}
class PhotosService {
async getEventPhotos(eventId: number, filters?: PhotoFilters): Promise<AdminPhoto[]> {
const params = new URLSearchParams();
if (filters) {
if (filters.category_id !== undefined) {
params.append('category_id', filters.category_id?.toString() || '');
}
if (filters.type) params.append('type', filters.type);
if (filters.search) params.append('search', filters.search);
if (filters.sort) params.append('sort', filters.sort);
if (filters.order) params.append('order', filters.order);
}
const queryString = params.toString();
const url = `/api/admin/events/${eventId}/photos${queryString ? `?${queryString}` : ''}`;
const response = await api.get(url);
// Return photos as-is, URLs are already relative API paths
return response.data.photos;
}
async deletePhoto(eventId: number, photoId: number): Promise<void> {
await api.delete(`/api/admin/events/${eventId}/photos/${photoId}`);
}
async deletePhotos(eventId: number, photoIds: number[]): Promise<void> {
await api.post(`/api/admin/events/${eventId}/photos/bulk-delete`, { photoIds });
}
async updatePhotoCategory(eventId: number, photoId: number, categoryId: number | null): Promise<void> {
await api.patch(`/api/admin/events/${eventId}/photos/${photoId}`, { category_id: categoryId });
}
async updatePhotosCategory(eventId: number, photoIds: number[], categoryId: number | null): Promise<void> {
await api.post(`/api/admin/events/${eventId}/photos/bulk-update`, {
photoIds,
updates: { category_id: categoryId }
});
}
async downloadPhoto(eventId: number, photoId: number, filename: string): Promise<void> {
const response = await api.get(`/api/admin/events/${eventId}/photos/${photoId}/download`, {
responseType: 'blob'
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
}
export const photosService = new PhotosService();