Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).
Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
This commit is contained in:
@@ -1,18 +1,21 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Page } from './_util';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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 } from 'lucide-react';
|
||||
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);
|
||||
@@ -22,6 +25,8 @@ export default function GalleryPage() {
|
||||
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');
|
||||
@@ -82,6 +87,109 @@ export default function GalleryPage() {
|
||||
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));
|
||||
@@ -111,17 +219,62 @@ export default function GalleryPage() {
|
||||
<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 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>
|
||||
@@ -133,24 +286,38 @@ export default function GalleryPage() {
|
||||
<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" />
|
||||
<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">{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>
|
||||
<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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user