reworked the guest pwa, modernized start and gallery page. added share link functionality.
This commit is contained in:
@@ -2,22 +2,38 @@ 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 { Heart, Image as ImageIcon, Share2 } 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 { fetchEvent, fetchStats, type EventData, type EventStats } from '../services/eventApi';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { sharePhotoLink } from '../lib/sharePhoto';
|
||||
import { useToast } from '../components/ToastHost';
|
||||
|
||||
const allowedGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth'];
|
||||
|
||||
const parseGalleryFilter = (value: string | null): GalleryFilter =>
|
||||
allowedGalleryFilters.includes(value as GalleryFilter) ? (value as GalleryFilter) : 'latest';
|
||||
|
||||
const normalizeImageUrl = (src?: string | null) => {
|
||||
if (!src) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (/^https?:/i.test(src)) {
|
||||
return src;
|
||||
}
|
||||
|
||||
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
|
||||
if (!cleanPath.startsWith('storage/')) {
|
||||
cleanPath = `storage/${cleanPath}`;
|
||||
}
|
||||
|
||||
return `/${cleanPath}`.replace(/\/+/g, '/');
|
||||
};
|
||||
|
||||
export default function GalleryPage() {
|
||||
const { token } = useParams<{ token?: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -30,11 +46,11 @@ export default function GalleryPage() {
|
||||
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 toast = useToast();
|
||||
const [shareTargetId, setShareTargetId] = React.useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFilterState(parseGalleryFilter(modeParam));
|
||||
@@ -64,13 +80,11 @@ export default function GalleryPage() {
|
||||
const loadEventData = async () => {
|
||||
try {
|
||||
setEventLoading(true);
|
||||
const [eventData, packageData, statsData] = await Promise.all([
|
||||
const [eventData, 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);
|
||||
@@ -106,67 +120,10 @@ 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 || galleryLimits.state !== 'expired') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tone: 'danger' as const,
|
||||
label: t('galleryCountdown.expired'),
|
||||
description: t('galleryCountdown.expiredDescription'),
|
||||
cta: 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}`),
|
||||
});
|
||||
}
|
||||
|
||||
if (galleryLimits?.state === 'expired') {
|
||||
warnings.push({
|
||||
id: 'gallery-expired',
|
||||
tone: 'danger',
|
||||
message: t('upload.errors.galleryExpired'),
|
||||
});
|
||||
}
|
||||
|
||||
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]);
|
||||
const totalLikes = React.useMemo(
|
||||
() => photos.reduce((sum, photo: any) => sum + (photo.likes_count ?? 0), 0),
|
||||
[photos],
|
||||
);
|
||||
|
||||
async function onLike(id: number) {
|
||||
if (liked.has(id)) return;
|
||||
@@ -185,6 +142,30 @@ export default function GalleryPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onShare(photo: any) {
|
||||
if (!token) return;
|
||||
setShareTargetId(photo.id);
|
||||
try {
|
||||
const result = await sharePhotoLink({
|
||||
token,
|
||||
photoId: photo.id,
|
||||
title: photo.task_title ?? event?.name ?? t('share.title', 'Geteiltes Foto'),
|
||||
text: t('share.shareText', { event: event?.name ?? 'Fotospiel' }),
|
||||
});
|
||||
|
||||
if (result.method === 'clipboard') {
|
||||
toast.push({ text: t('share.copySuccess', 'Link kopiert!') });
|
||||
} else if (result.method === 'manual') {
|
||||
window.prompt(t('share.manualPrompt', 'Link kopieren'), result.url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('share failed', error);
|
||||
toast.push({ text: t('share.error', 'Teilen fehlgeschlagen'), type: 'error' });
|
||||
} finally {
|
||||
setShareTargetId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return <Page title="Galerie"><p>Event nicht gefunden.</p></Page>;
|
||||
}
|
||||
@@ -195,183 +176,109 @@ export default function GalleryPage() {
|
||||
|
||||
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>
|
||||
<section className="space-y-4 px-4">
|
||||
<div className="rounded-[32px] border border-white/40 bg-gradient-to-br from-pink-50 via-white to-white p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Live-Galerie</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-2xl font-semibold text-foreground">
|
||||
<ImageIcon className="h-6 w-6 text-pink-500" aria-hidden />
|
||||
<span>{event?.name ?? 'Event'}</span>
|
||||
</div>
|
||||
</Card>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{photos.length} Fotos · {totalLikes} ❤️ · {stats?.onlineGuests ?? 0} Gäste online
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2 sm:items-end">
|
||||
{newCount > 0 && (
|
||||
<Button size="sm" className="rounded-full" onClick={acknowledgeNew}>
|
||||
{newCount} neue Fotos ansehen
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/e/${encodeURIComponent(token)}/upload`)}
|
||||
>
|
||||
Neues Foto hochladen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<FiltersBar value={filter} onChange={setFilter} className="mt-2" />
|
||||
{loading && <p className="px-4">Lade…</p>}
|
||||
<div className="grid gap-3 px-4 pb-16 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{list.map((p: any) => {
|
||||
const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path);
|
||||
const createdLabel = p.created_at
|
||||
? new Date(p.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: t('gallery.justNow', 'Gerade eben');
|
||||
const likeCount = counts[p.id] ?? (p.likes_count || 0);
|
||||
|
||||
const openPhoto = () => {
|
||||
const index = list.findIndex((photo: any) => photo.id === p.id);
|
||||
setCurrentPhotoIndex(index >= 0 ? index : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openPhoto}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
openPhoto();
|
||||
}
|
||||
}}
|
||||
className="group relative overflow-hidden rounded-[28px] border border-white/20 bg-gray-950 text-white shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400"
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={`Foto ${p.id}${p.task_title ? ` - ${p.task_title}` : ''}`}
|
||||
className="h-64 w-full object-cover transition duration-500 group-hover:scale-105"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent" aria-hidden />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 space-y-2 px-4 pb-4">
|
||||
{p.task_title && <p className="text-sm font-medium leading-tight line-clamp-2">{p.task_title}</p>}
|
||||
<div className="flex items-center justify-between text-xs text-white/80">
|
||||
<span>{createdLabel}</span>
|
||||
<span>{p.uploader_name || 'Gast'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShare(p);
|
||||
}}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full border border-white/30 bg-white/10 transition ${shareTargetId === p.id ? 'opacity-60' : 'hover:bg-white/20'}`}
|
||||
aria-label={t('share.button', 'Teilen')}
|
||||
disabled={shareTargetId === p.id}
|
||||
>
|
||||
<Share2 className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onLike(p.id);
|
||||
}}
|
||||
className={`flex items-center gap-1 rounded-full border border-white/30 bg-white/10 px-3 py-1 text-sm font-medium transition ${liked.has(p.id) ? 'text-pink-300' : 'text-white'}`}
|
||||
aria-label="Foto liken"
|
||||
>
|
||||
<Heart className={`h-4 w-4 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
|
||||
{likeCount}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user