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
@@ -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';