Update New Features
This commit is contained in:
354
src/pages/admin/AdminGallery.js
Normal file
354
src/pages/admin/AdminGallery.js
Normal file
@@ -0,0 +1,354 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { Textarea } from '../../components/ui/textarea';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../../components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../components/ui/dialog';
|
||||
import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import moment from 'moment';
|
||||
|
||||
const AdminGallery = () => {
|
||||
const [events, setEvents] = useState([]);
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [galleryImages, setGalleryImages] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [editingCaption, setEditingCaption] = useState(null);
|
||||
const [newCaption, setNewCaption] = useState('');
|
||||
const [maxFileSize, setMaxFileSize] = useState(5242880); // Default 5MB
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
fetchConfigLimits();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEvent) {
|
||||
fetchGallery(selectedEvent);
|
||||
}
|
||||
}, [selectedEvent]);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
const response = await api.get('/admin/events');
|
||||
setEvents(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch events:', error);
|
||||
toast.error('Failed to load events');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGallery = async (eventId) => {
|
||||
try {
|
||||
const response = await api.get(`/events/${eventId}/gallery`);
|
||||
setGalleryImages(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch gallery:', error);
|
||||
toast.error('Failed to load gallery');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConfigLimits = async () => {
|
||||
try {
|
||||
const response = await api.get('/config/limits');
|
||||
setMaxFileSize(response.data.max_file_size_bytes);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config limits:', error);
|
||||
// Keep default value (5MB) if fetch fails
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
else if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
else return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
await api.post(`/admin/events/${selectedEvent}/gallery`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(`${files.length} ${files.length === 1 ? 'image' : 'images'} uploaded successfully`);
|
||||
await fetchGallery(selectedEvent);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload images:', error);
|
||||
toast.error(error.response?.data?.detail || 'Failed to upload images');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteImage = async (imageId) => {
|
||||
if (!window.confirm('Are you sure you want to delete this image?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.delete(`/admin/event-gallery/${imageId}`);
|
||||
toast.success('Image deleted successfully');
|
||||
await fetchGallery(selectedEvent);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete image:', error);
|
||||
toast.error('Failed to delete image');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateCaption = async () => {
|
||||
if (!editingCaption) return;
|
||||
|
||||
try {
|
||||
await api.put(`/admin/event-gallery/${editingCaption.id}`, null, {
|
||||
params: { caption: newCaption }
|
||||
});
|
||||
toast.success('Caption updated successfully');
|
||||
setEditingCaption(null);
|
||||
setNewCaption('');
|
||||
await fetchGallery(selectedEvent);
|
||||
} catch (error) {
|
||||
console.error('Failed to update caption:', error);
|
||||
toast.error('Failed to update caption');
|
||||
}
|
||||
};
|
||||
|
||||
const openEditCaption = (image) => {
|
||||
setEditingCaption(image);
|
||||
setNewCaption(image.caption || '');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Event Gallery Management
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Upload and manage photos for event galleries
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Event Selection */}
|
||||
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Select Event
|
||||
</Label>
|
||||
<Select value={selectedEvent || ''} onValueChange={setSelectedEvent}>
|
||||
<SelectTrigger className="border-[#ddd8eb] rounded-xl">
|
||||
<SelectValue placeholder="Choose an event..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{events.map((event) => (
|
||||
<SelectItem key={event.id} value={event.id}>
|
||||
{event.title} - {moment(event.start_at).format('MMM D, YYYY')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedEvent && (
|
||||
<div className="pt-4">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Upload className="h-4 w-4 mr-2 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Upload Images
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-sm text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
You can select multiple images. Max {formatFileSize(maxFileSize)} per image.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Gallery Grid */}
|
||||
{selectedEvent && (
|
||||
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Gallery Images
|
||||
</h2>
|
||||
<Badge className="bg-[#664fa3] text-white px-3 py-1">
|
||||
{galleryImages.length} {galleryImages.length === 1 ? 'image' : 'images'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{galleryImages.length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{galleryImages.map((image) => (
|
||||
<div key={image.id} className="relative group">
|
||||
<div className="aspect-square rounded-xl overflow-hidden bg-[#F8F7FB]">
|
||||
<img
|
||||
src={image.image_url}
|
||||
alt={image.caption || 'Gallery image'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Overlay with Actions */}
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex flex-col items-center justify-center gap-2">
|
||||
<Button
|
||||
onClick={() => openEditCaption(image)}
|
||||
size="sm"
|
||||
className="bg-white/90 hover:bg-white text-[#422268] rounded-lg"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Caption
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDeleteImage(image.id)}
|
||||
size="sm"
|
||||
className="bg-red-500 hover:bg-red-600 text-white rounded-lg"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Caption Preview */}
|
||||
{image.caption && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-[#664fa3] line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{image.caption}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Size */}
|
||||
<div className="mt-1">
|
||||
<p className="text-xs text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{formatFileSize(image.file_size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<ImageIcon className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Images Yet
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Upload images to create a gallery for this event.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Edit Caption Dialog */}
|
||||
<Dialog open={!!editingCaption} onOpenChange={() => setEditingCaption(null)}>
|
||||
<DialogContent className="bg-white sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Edit Image Caption
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{editingCaption && (
|
||||
<div className="space-y-4">
|
||||
<div className="aspect-video rounded-xl overflow-hidden bg-[#F8F7FB]">
|
||||
<img
|
||||
src={editingCaption.image_url}
|
||||
alt="Preview"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Caption
|
||||
</Label>
|
||||
<Textarea
|
||||
value={newCaption}
|
||||
onChange={(e) => setNewCaption(e.target.value)}
|
||||
placeholder="Add a caption for this image..."
|
||||
rows={3}
|
||||
className="border-[#ddd8eb] rounded-xl mt-2"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => setEditingCaption(null)}
|
||||
variant="outline"
|
||||
className="border-[#ddd8eb] text-[#664fa3] rounded-xl"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpdateCaption}
|
||||
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Save Caption
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminGallery;
|
||||
Reference in New Issue
Block a user