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:
@@ -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} />;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user