306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { useLanguage } from '@/context/LanguageContext';
|
|
import { mediaApi, Media } from '@/lib/api';
|
|
import Card from '@/components/ui/Card';
|
|
import Button from '@/components/ui/Button';
|
|
import {
|
|
PhotoIcon,
|
|
TrashIcon,
|
|
ArrowUpTrayIcon,
|
|
XMarkIcon,
|
|
MagnifyingGlassIcon,
|
|
LinkIcon,
|
|
CheckIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import toast from 'react-hot-toast';
|
|
|
|
export default function AdminGalleryPage() {
|
|
const { locale } = useLanguage();
|
|
const [media, setMedia] = useState<Media[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [selectedImage, setSelectedImage] = useState<Media | null>(null);
|
|
const [filter, setFilter] = useState<string>('');
|
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
loadMedia();
|
|
}, [filter]);
|
|
|
|
const loadMedia = async () => {
|
|
try {
|
|
// We need to call the media API - let's add it if it doesn't exist
|
|
const res = await fetch('/api/media', {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
|
|
},
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setMedia(data.media || []);
|
|
}
|
|
} catch (error) {
|
|
toast.error('Failed to load media');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
setUploading(true);
|
|
let successCount = 0;
|
|
let failCount = 0;
|
|
|
|
for (const file of Array.from(files)) {
|
|
try {
|
|
await mediaApi.upload(file, undefined, 'gallery');
|
|
successCount++;
|
|
} catch (error) {
|
|
failCount++;
|
|
}
|
|
}
|
|
|
|
if (successCount > 0) {
|
|
toast.success(`${successCount} image(s) uploaded successfully`);
|
|
}
|
|
if (failCount > 0) {
|
|
toast.error(`${failCount} image(s) failed to upload`);
|
|
}
|
|
|
|
loadMedia();
|
|
setUploading(false);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm('Are you sure you want to delete this image?')) return;
|
|
|
|
try {
|
|
await mediaApi.delete(id);
|
|
toast.success('Image deleted');
|
|
loadMedia();
|
|
if (selectedImage?.id === id) {
|
|
setSelectedImage(null);
|
|
}
|
|
} catch (error) {
|
|
toast.error('Failed to delete image');
|
|
}
|
|
};
|
|
|
|
const copyUrl = async (url: string, id: string) => {
|
|
const fullUrl = window.location.origin + url;
|
|
try {
|
|
await navigator.clipboard.writeText(fullUrl);
|
|
setCopiedId(id);
|
|
toast.success('URL copied');
|
|
setTimeout(() => setCopiedId(null), 2000);
|
|
} catch (error) {
|
|
toast.error('Failed to copy URL');
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
});
|
|
};
|
|
|
|
const filteredMedia = media.filter(m => {
|
|
if (!filter) return true;
|
|
if (filter === 'gallery') return m.relatedType === 'gallery' || !m.relatedType;
|
|
if (filter === 'event') return m.relatedType === 'event';
|
|
return true;
|
|
});
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-2xl font-bold text-primary-dark">Gallery Management</h1>
|
|
<Button onClick={() => fileInputRef.current?.click()} isLoading={uploading}>
|
|
<ArrowUpTrayIcon className="w-5 h-5 mr-2" />
|
|
Upload Images
|
|
</Button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/gif,image/webp,image/avif"
|
|
multiple
|
|
onChange={handleUpload}
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
<Card className="p-4 text-center">
|
|
<p className="text-3xl font-bold text-primary-dark">{media.length}</p>
|
|
<p className="text-sm text-gray-500">Total Images</p>
|
|
</Card>
|
|
<Card className="p-4 text-center">
|
|
<p className="text-3xl font-bold text-blue-600">
|
|
{media.filter(m => m.relatedType === 'event').length}
|
|
</p>
|
|
<p className="text-sm text-gray-500">Event Images</p>
|
|
</Card>
|
|
<Card className="p-4 text-center">
|
|
<p className="text-3xl font-bold text-green-600">
|
|
{media.filter(m => m.relatedType === 'gallery' || !m.relatedType).length}
|
|
</p>
|
|
<p className="text-sm text-gray-500">Gallery Images</p>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Filter */}
|
|
<Card className="p-4 mb-6">
|
|
<div className="flex items-center gap-4">
|
|
<label className="text-sm font-medium">Filter:</label>
|
|
<select
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
className="px-4 py-2 rounded-btn border border-secondary-light-gray"
|
|
>
|
|
<option value="">All Images</option>
|
|
<option value="gallery">Gallery Only</option>
|
|
<option value="event">Event Banners</option>
|
|
</select>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Gallery Grid */}
|
|
{filteredMedia.length === 0 ? (
|
|
<Card className="p-12 text-center">
|
|
<PhotoIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
|
<h3 className="text-lg font-semibold text-gray-600 mb-2">No images yet</h3>
|
|
<p className="text-gray-500 mb-4">Upload images to build your gallery</p>
|
|
<Button onClick={() => fileInputRef.current?.click()}>
|
|
<ArrowUpTrayIcon className="w-5 h-5 mr-2" />
|
|
Upload First Image
|
|
</Button>
|
|
</Card>
|
|
) : (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
{filteredMedia.map((item) => (
|
|
<Card key={item.id} className="group relative overflow-hidden aspect-square">
|
|
<img
|
|
src={item.fileUrl}
|
|
alt=""
|
|
className="w-full h-full object-cover cursor-pointer hover:scale-105 transition-transform"
|
|
onClick={() => setSelectedImage(item)}
|
|
/>
|
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
|
<button
|
|
onClick={() => setSelectedImage(item)}
|
|
className="p-2 bg-white rounded-full hover:bg-gray-100"
|
|
title="View"
|
|
>
|
|
<MagnifyingGlassIcon className="w-5 h-5" />
|
|
</button>
|
|
<button
|
|
onClick={() => copyUrl(item.fileUrl, item.id)}
|
|
className="p-2 bg-white rounded-full hover:bg-gray-100"
|
|
title="Copy URL"
|
|
>
|
|
{copiedId === item.id ? (
|
|
<CheckIcon className="w-5 h-5 text-green-600" />
|
|
) : (
|
|
<LinkIcon className="w-5 h-5" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(item.id)}
|
|
className="p-2 bg-white rounded-full hover:bg-red-100 text-red-600"
|
|
title="Delete"
|
|
>
|
|
<TrashIcon className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
{item.relatedType && (
|
|
<div className="absolute top-2 left-2">
|
|
<span className={`text-xs px-2 py-1 rounded ${
|
|
item.relatedType === 'event' ? 'bg-blue-500 text-white' : 'bg-green-500 text-white'
|
|
}`}>
|
|
{item.relatedType === 'event' ? 'Event' : 'Gallery'}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Image Preview Modal */}
|
|
{selectedImage && (
|
|
<div
|
|
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
|
|
onClick={() => setSelectedImage(null)}
|
|
>
|
|
<button
|
|
className="absolute top-4 right-4 text-white hover:text-gray-300"
|
|
onClick={() => setSelectedImage(null)}
|
|
>
|
|
<XMarkIcon className="w-8 h-8" />
|
|
</button>
|
|
|
|
<div
|
|
className="max-w-4xl max-h-[80vh] flex flex-col items-center"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<img
|
|
src={selectedImage.fileUrl}
|
|
alt=""
|
|
className="max-w-full max-h-[70vh] object-contain rounded-lg"
|
|
/>
|
|
<div className="mt-4 bg-white rounded-lg p-4 w-full max-w-md">
|
|
<p className="text-sm text-gray-500 mb-2">
|
|
Uploaded: {formatDate(selectedImage.createdAt)}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={window.location.origin + selectedImage.fileUrl}
|
|
readOnly
|
|
className="flex-1 px-3 py-2 text-sm border rounded-btn bg-gray-50"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => copyUrl(selectedImage.fileUrl, selectedImage.id)}
|
|
>
|
|
{copiedId === selectedImage.id ? 'Copied!' : 'Copy'}
|
|
</Button>
|
|
</div>
|
|
<div className="flex gap-2 mt-4">
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={() => handleDelete(selectedImage.id)}
|
|
>
|
|
<TrashIcon className="w-4 h-4 mr-2" />
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|