Files
fotospiel-app/resources/js/guest/pages/GalleryPage.tsx
Codex Agent 79b209de9a Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).
Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
2025-11-01 19:50:17 +01:00

412 lines
16 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { Page } from './_util';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
import { Heart, Users, Image as ImageIcon, Camera, Package as PackageIcon, AlertTriangle } 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';
import { useTranslation } from '../i18n/useTranslation';
export default function GalleryPage() {
const { token } = useParams<{ token?: string }>();
const navigate = useNavigate();
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '');
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 { t } = useTranslation();
const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE';
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 (!token) return;
const loadEventData = async () => {
try {
setEventLoading(true);
const [eventData, packageData, statsData] = await Promise.all([
fetchEvent(token),
getEventPackage(token),
fetchStats(token),
]);
setEvent(eventData);
setEventPackage(packageData);
setStats(statsData);
} catch (err) {
console.error('Failed to load event data', err);
} finally {
setEventLoading(false);
}
};
loadEventData();
}, [token]);
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>>({});
const photoLimits = eventPackage?.limits?.photos ?? null;
const guestLimits = eventPackage?.limits?.guests ?? null;
const galleryLimits = eventPackage?.limits?.gallery ?? null;
const galleryCountdown = React.useMemo(() => {
if (!galleryLimits) {
return null;
}
if (galleryLimits.state === 'expired') {
return {
tone: 'danger' as const,
label: t('galleryCountdown.expired'),
description: t('galleryCountdown.expiredDescription'),
cta: null,
};
}
if (galleryLimits.state === 'warning') {
const days = Math.max(0, galleryLimits.days_remaining ?? 0);
const label = days <= 1
? t('galleryCountdown.expiresToday')
: t('galleryCountdown.expiresIn').replace('{days}', `${days}`);
return {
tone: days <= 1 ? ('danger' as const) : ('warning' as const),
label,
description: t('galleryCountdown.description'),
cta: {
type: 'upload' as const,
label: t('galleryCountdown.ctaUpload'),
},
};
}
return null;
}, [galleryLimits, t]);
const handleCountdownCta = React.useCallback(() => {
if (!galleryCountdown?.cta || !token) {
return;
}
if (galleryCountdown.cta.type === 'upload') {
navigate(`/e/${encodeURIComponent(token)}/upload`);
}
}, [galleryCountdown?.cta, navigate, token]);
const packageWarnings = React.useMemo(() => {
const warnings: { id: string; tone: 'warning' | 'danger'; message: string }[] = [];
if (photoLimits?.state === 'limit_reached' && typeof photoLimits.limit === 'number') {
warnings.push({
id: 'photos-blocked',
tone: 'danger',
message: t('upload.limitReached')
.replace('{used}', `${photoLimits.used}`)
.replace('{max}', `${photoLimits.limit}`),
});
} else if (
photoLimits?.state === 'warning'
&& typeof photoLimits.remaining === 'number'
&& typeof photoLimits.limit === 'number'
) {
warnings.push({
id: 'photos-warning',
tone: 'warning',
message: t('upload.limitWarning')
.replace('{remaining}', `${photoLimits.remaining}`)
.replace('{max}', `${photoLimits.limit}`),
});
}
if (galleryLimits?.state === 'expired') {
warnings.push({
id: 'gallery-expired',
tone: 'danger',
message: t('upload.errors.galleryExpired'),
});
} else if (galleryLimits?.state === 'warning') {
const days = Math.max(0, galleryLimits.days_remaining ?? 0);
const key = days === 1 ? 'upload.galleryWarningDay' : 'upload.galleryWarningDays';
warnings.push({
id: 'gallery-warning',
tone: 'warning',
message: t(key).replace('{days}', `${days}`),
});
}
return warnings;
}, [photoLimits, galleryLimits, t]);
const formatDate = React.useCallback((value: string | null) => {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
try {
return new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short', year: 'numeric' }).format(date);
} catch {
return date.toISOString().slice(0, 10);
}
}, [locale]);
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 (!token) {
return <Page title="Galerie"><p>Event nicht gefunden.</p></Page>;
}
if (eventLoading) {
return <Page title="Galerie"><p>Lade Event-Info...</p></Page>;
}
return (
<Page title="Galerie">
<Card className="mx-4 mb-4">
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex flex-wrap items-center gap-2">
<ImageIcon className="h-6 w-6" />
<span>Galerie: {event?.name || 'Event'}</span>
{galleryCountdown && (
<Badge
variant="secondary"
className={galleryCountdown.tone === 'danger'
? 'border-rose-200 bg-rose-100 text-rose-700'
: 'border-amber-200 bg-amber-100 text-amber-700'}
>
{galleryCountdown.label}
</Badge>
)}
</CardTitle>
{galleryCountdown?.cta && (
<Button
size="sm"
variant={galleryCountdown.tone === 'danger' ? 'destructive' : 'outline'}
onClick={handleCountdownCta}
disabled={!token}
>
{galleryCountdown.cta.label}
</Button>
)}
</div>
{galleryCountdown && (
<CardDescription className={galleryCountdown.tone === 'danger' ? 'text-rose-600' : 'text-amber-600'}>
{galleryCountdown.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-4">
{packageWarnings.length > 0 && (
<div className="space-y-2">
{packageWarnings.map((warning) => (
<Alert
key={warning.id}
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
>
<AlertDescription className="flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4" />
{warning.message}
</AlertDescription>
</Alert>
))}
</div>
)}
<div className="grid grid-cols-2 gap-4 md:grid-cols-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="rounded-2xl border border-gray-200 bg-white/70 p-4 text-center">
<PackageIcon className="mx-auto mb-2 h-8 w-8 text-purple-500" />
<p className="font-semibold">Package</p>
<p className="text-sm text-gray-600">{eventPackage.package?.name ?? '—'}</p>
{photoLimits?.limit ? (
<>
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-gray-200">
<div
className={`h-2 rounded-full ${photoLimits.state === 'limit_reached' ? 'bg-red-500' : photoLimits.state === 'warning' ? 'bg-amber-500' : 'bg-blue-600'}`}
style={{ width: `${Math.min(100, Math.max(6, Math.round((photoLimits.used / photoLimits.limit) * 100))) }%` }}
/>
</div>
<p className="mt-2 text-xs text-gray-600">
{photoLimits.used} / {photoLimits.limit} Fotos
</p>
</>
) : (
<p className="mt-2 text-xs text-gray-600">{t('upload.limitUnlimited')}</p>
)}
{guestLimits?.limit ? (
<p className="mt-2 text-xs text-gray-500">
Gäste: {guestLimits.used} / {guestLimits.limit}
</p>
) : null}
{galleryLimits?.expires_at ? (
<p className="mt-2 text-xs text-gray-500">
Galerie bis {formatDate(galleryLimits.expires_at)}
</p>
) : null}
</div>
)}
</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)}
token={token}
/>
)}
</Page>
);
}