241 lines
9.9 KiB
TypeScript
241 lines
9.9 KiB
TypeScript
import React, { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
|
import { Page } from './_util';
|
|
import { useParams, useSearchParams } from 'react-router-dom';
|
|
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { Button } from '@/components/ui/button';
|
|
import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
|
|
import { Heart, Users, Image as ImageIcon, Camera, Package as PackageIcon } from 'lucide-react';
|
|
import { likePhoto } from '../services/photosApi';
|
|
import PhotoLightbox from './PhotoLightbox';
|
|
import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi';
|
|
|
|
export default function GalleryPage() {
|
|
const { slug } = useParams();
|
|
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(slug!);
|
|
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
|
|
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
|
|
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
|
|
|
|
const [event, setEvent] = useState<EventData | null>(null);
|
|
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
|
|
const [stats, setStats] = useState<EventStats | null>(null);
|
|
const [eventLoading, setEventLoading] = useState(true);
|
|
|
|
const [searchParams] = useSearchParams();
|
|
const photoIdParam = searchParams.get('photoId');
|
|
// Auto-open lightbox if photoId in query params
|
|
useEffect(() => {
|
|
if (photoIdParam && photos.length > 0 && currentPhotoIndex === null && !hasOpenedPhoto) {
|
|
const index = photos.findIndex((photo: any) => photo.id === parseInt(photoIdParam, 10));
|
|
if (index !== -1) {
|
|
setCurrentPhotoIndex(index);
|
|
setHasOpenedPhoto(true);
|
|
}
|
|
}
|
|
}, [photos, photoIdParam, currentPhotoIndex, hasOpenedPhoto]);
|
|
|
|
// Load event and package info
|
|
useEffect(() => {
|
|
if (!slug) return;
|
|
|
|
const loadEventData = async () => {
|
|
try {
|
|
setEventLoading(true);
|
|
const [eventData, packageData, statsData] = await Promise.all([
|
|
fetchEvent(slug),
|
|
getEventPackage(slug),
|
|
fetchStats(slug),
|
|
]);
|
|
setEvent(eventData);
|
|
setEventPackage(packageData);
|
|
setStats(statsData);
|
|
} catch (err) {
|
|
console.error('Failed to load event data', err);
|
|
} finally {
|
|
setEventLoading(false);
|
|
}
|
|
};
|
|
|
|
loadEventData();
|
|
}, [slug]);
|
|
|
|
const myPhotoIds = React.useMemo(() => {
|
|
try {
|
|
const raw = localStorage.getItem('my-photo-ids');
|
|
return new Set<number>(raw ? JSON.parse(raw) : []);
|
|
} catch { return new Set<number>(); }
|
|
}, []);
|
|
|
|
const list = React.useMemo(() => {
|
|
let arr = photos.slice();
|
|
if (filter === 'popular') {
|
|
arr.sort((a: any, b: any) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
|
|
} else if (filter === 'mine') {
|
|
arr = arr.filter((p: any) => myPhotoIds.has(p.id));
|
|
} else {
|
|
arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
|
|
}
|
|
return arr;
|
|
}, [photos, filter, myPhotoIds]);
|
|
const [liked, setLiked] = React.useState<Set<number>>(new Set());
|
|
const [counts, setCounts] = React.useState<Record<number, number>>({});
|
|
|
|
async function onLike(id: number) {
|
|
if (liked.has(id)) return;
|
|
setLiked(new Set(liked).add(id));
|
|
try {
|
|
const c = await likePhoto(id);
|
|
setCounts((m) => ({ ...m, [id]: c }));
|
|
// keep a simple record of liked items
|
|
try {
|
|
const raw = localStorage.getItem('liked-photo-ids');
|
|
const arr: number[] = raw ? JSON.parse(raw) : [];
|
|
if (!arr.includes(id)) localStorage.setItem('liked-photo-ids', JSON.stringify([...arr, id]));
|
|
} catch {}
|
|
} catch {
|
|
const s = new Set(liked); s.delete(id); setLiked(s);
|
|
}
|
|
}
|
|
|
|
if (eventLoading) {
|
|
return <Page title="Galerie"><p>Lade Event-Info...</p></Page>;
|
|
}
|
|
|
|
return (
|
|
<Page title="Galerie">
|
|
<Card className="mx-4 mb-4">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<ImageIcon className="h-6 w-6" />
|
|
Galerie: {event?.name || 'Event'}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div className="text-center">
|
|
<Users className="h-8 w-8 mx-auto mb-2 text-blue-500" />
|
|
<p className="font-semibold">Online Gäste</p>
|
|
<p className="text-2xl">{stats?.onlineGuests || 0}</p>
|
|
</div>
|
|
<div className="text-center">
|
|
<Heart className="h-8 w-8 mx-auto mb-2 text-red-500" />
|
|
<p className="font-semibold">Gesamt Likes</p>
|
|
<p className="text-2xl">{photos.reduce((sum, p) => sum + ((p as any).likes_count || 0), 0)}</p>
|
|
</div>
|
|
<div className="text-center">
|
|
<Camera className="h-8 w-8 mx-auto mb-2 text-green-500" />
|
|
<p className="font-semibold">Gesamt Fotos</p>
|
|
<p className="text-2xl">{photos.length}</p>
|
|
</div>
|
|
{eventPackage && (
|
|
<div className="text-center">
|
|
<PackageIcon className="h-8 w-8 mx-auto mb-2 text-purple-500" />
|
|
<p className="font-semibold">Package</p>
|
|
<p className="text-sm">{eventPackage.package.name}</p>
|
|
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
|
|
<div
|
|
className="bg-blue-600 h-2 rounded-full"
|
|
style={{ width: `${(eventPackage.used_photos / eventPackage.package.max_photos) * 100}%` }}
|
|
></div>
|
|
</div>
|
|
<p className="text-xs text-gray-600 mt-1">
|
|
{eventPackage.used_photos} / {eventPackage.package.max_photos} Fotos
|
|
</p>
|
|
{new Date(eventPackage.expires_at) < new Date() && (
|
|
<p className="text-red-600 text-xs mt-1">Abgelaufen: {new Date(eventPackage.expires_at).toLocaleDateString()}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<FiltersBar value={filter} onChange={setFilter} />
|
|
{newCount > 0 && (
|
|
<Alert className="mb-3 mx-4">
|
|
<AlertDescription>
|
|
{newCount} neue Fotos verfügbar.{' '}
|
|
<Button variant="link" className="px-1" onClick={acknowledgeNew}>Aktualisieren</Button>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
{loading && <p className="mx-4">Lade…</p>}
|
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 px-4">
|
|
{list.map((p: any) => {
|
|
// Debug: Log image URLs
|
|
const imgSrc = p.thumbnail_path || p.file_path;
|
|
|
|
// Normalize image URL
|
|
let imageUrl = imgSrc;
|
|
let cleanPath = '';
|
|
|
|
if (imageUrl) {
|
|
// Remove leading/trailing slashes for processing
|
|
cleanPath = imageUrl.replace(/^\/+|\/+$/g, '');
|
|
|
|
// Check if path already contains storage prefix
|
|
if (cleanPath.startsWith('storage/')) {
|
|
// Already has storage prefix, just ensure it starts with /
|
|
imageUrl = `/${cleanPath}`;
|
|
} else {
|
|
// Add storage prefix
|
|
imageUrl = `/storage/${cleanPath}`;
|
|
}
|
|
|
|
// Remove double slashes
|
|
imageUrl = imageUrl.replace(/\/+/g, '/');
|
|
}
|
|
|
|
// Production: avoid heavy console logging for each image
|
|
|
|
return (
|
|
<Card key={p.id} className="relative overflow-hidden">
|
|
<CardContent className="p-0">
|
|
<div
|
|
onClick={() => {
|
|
const index = list.findIndex(photo => photo.id === p.id);
|
|
setCurrentPhotoIndex(index >= 0 ? index : null);
|
|
}}
|
|
className="cursor-pointer"
|
|
>
|
|
<img
|
|
src={imageUrl}
|
|
alt={`Foto ${p.id}${p.task_title ? ` - ${p.task_title}` : ''}`}
|
|
className="aspect-square w-full object-cover bg-gray-200"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
|
|
}}
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
|
|
{p.task_title && (
|
|
<div className="px-2 pb-2 text-center">
|
|
<p className="text-xs text-gray-700 truncate bg-white/80 py-1 rounded-sm">{p.task_title}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="absolute bottom-1 right-1 flex items-center gap-1 rounded-full bg-black/50 px-2 py-1 text-white">
|
|
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onLike(p.id); }} className={`inline-flex items-center ${liked.has(p.id) ? 'text-red-400' : ''}`} aria-label="Like">
|
|
<Heart className="h-4 w-4" />
|
|
</button>
|
|
<span className="text-xs">{counts[p.id] ?? (p.likes_count || 0)}</span>
|
|
</div>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
{currentPhotoIndex !== null && list.length > 0 && (
|
|
<PhotoLightbox
|
|
photos={list}
|
|
currentIndex={currentPhotoIndex}
|
|
onClose={() => setCurrentPhotoIndex(null)}
|
|
onIndexChange={(index: number) => setCurrentPhotoIndex(index)}
|
|
slug={slug}
|
|
/>
|
|
)}
|
|
</Page>
|
|
);
|
|
}
|