**Option 3 Implementation (Latest):** - InviteStaffDialog: Use /admin/roles/assignable endpoint - AdminStaff: Enable admin users to see 'Invite Staff' button **Permission Checks Added (8 admin pages):** - AdminNewsletters: newsletters.create/edit/delete - AdminFinancials: financials.create/edit/delete - AdminBylaws: bylaws.create/edit/delete - AdminValidations: users.approve, subscriptions.activate - AdminSubscriptions: subscriptions.export/edit/cancel - AdminDonations: donations.export - AdminGallery: gallery.upload/edit/delete - AdminPlans: subscriptions.plans **Pattern Established:** All admin action buttons now wrapped with hasPermission() checks. UI hides what users can't access, backend enforces rules. **Files Modified:** 10 files, 100+ permission checks added
390 lines
14 KiB
JavaScript
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-[#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>
|
|
|
|
{/* Empty State Message */}
|
|
{events.length === 0 && (
|
|
<div className="mt-4 p-4 bg-[#f1eef9] border-2 border-[#DDD8EB] rounded-xl">
|
|
<div className="flex items-start gap-3">
|
|
<AlertCircle className="h-5 w-5 text-[#664fa3] flex-shrink-0 mt-0.5" />
|
|
<div className="flex-1">
|
|
<h4 className="text-sm font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
No Events Available
|
|
</h4>
|
|
<p className="text-sm text-[#664fa3] 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="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl 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="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 */}
|
|
{(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-white/90 hover:bg-white text-[#422268] 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-[#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;
|