Files
membership-fe/src/pages/admin/AdminGallery.js

390 lines
14 KiB
JavaScript

import React, { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
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, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
import moment from 'moment';
const AdminGallery = () => {
const { hasPermission } = useAuth();
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-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Event Gallery Management
</h1>
<p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Upload and manage photos for event galleries
</p>
</div>
{/* Event Selection */}
<Card className="p-6 bg-background border-[var(--neutral-800)] rounded-xl">
<div className="space-y-4">
<div>
<Label className="text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Select Event
</Label>
<Select value={selectedEvent || ''} onValueChange={setSelectedEvent}>
<SelectTrigger className="border-[var(--neutral-800)] 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>
{/* Empty State Message */}
{events.length === 0 && (
<div className="mt-4 p-4 bg-[var(--lavender-300)] border-2 border-[var(--neutral-800)] rounded-xl">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-brand-purple flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
No Events Available
</h4>
<p className="text-sm text-brand-purple mb-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
You need to create an event before uploading gallery images. Events help organize photos by occasion.
</p>
<Link to="/admin/events">
<Button
className="btn-lavender text-sm"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<Calendar className="h-4 w-4 mr-2" />
Create Your First Event
</Button>
</Link>
</div>
</div>
</div>
)}
{selectedEvent && hasPermission('gallery.upload') && (
<div className="pt-4">
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept="image/*"
multiple
className="hidden"
/>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="btn-lavender "
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-brand-purple 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-background border-[var(--neutral-800)] rounded-xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Gallery Images
</h2>
<Badge variant="purple" className=" 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-[var(--lavender-500)]">
<img
src={image.image_url}
alt={image.caption || 'Gallery image'}
className="w-full h-full object-cover"
/>
</div>
{/* Overlay with Actions */}
{(hasPermission('gallery.edit') || hasPermission('gallery.delete')) && (
<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">
{hasPermission('gallery.edit') && (
<Button
onClick={() => openEditCaption(image)}
size="sm"
className="bg-background/90 hover:bg-background text-[var(--purple-ink)] dark:text-[#ddd8eb] rounded-lg"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<Edit className="h-4 w-4 mr-1" />
Caption
</Button>
)}
{hasPermission('gallery.delete') && (
<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-brand-purple line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{image.caption}
</p>
</div>
)}
{/* File Size */}
<div className="mt-1">
<p className="text-xs text-brand-purple " 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-[var(--neutral-800)] mx-auto mb-4" />
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
No Images Yet
</h3>
<p className="text-brand-purple " 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-background sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-[var(--purple-ink)]" 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-[var(--lavender-500)]">
<img
src={editingCaption.image_url}
alt="Preview"
className="w-full h-full object-contain"
/>
</div>
<div>
<Label className="text-[var(--purple-ink)]" 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-[var(--neutral-800)] rounded-xl mt-2"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/>
</div>
</div>
)}
<DialogFooter>
<Button
onClick={() => setEditingCaption(null)}
variant="outline"
className="border-[var(--neutral-800)] text-brand-purple rounded-xl"
style={{ fontFamily: "'Inter', sans-serif" }}
>
Cancel
</Button>
<Button
onClick={handleUpdateCaption}
className="bg-brand-purple hover:bg-[var(--purple-ink)] text-white rounded-xl"
style={{ fontFamily: "'Inter', sans-serif" }}
>
Save Caption
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default AdminGallery;