import React, { useEffect, useState } from 'react'; import { Page } from './_util'; import { useParams, useSearchParams } from 'react-router-dom'; import { usePollGalleryDelta } from '../polling/usePollGalleryDelta'; import FiltersBar, { type GalleryFilter } from '../components/FiltersBar'; import { Heart, Image as ImageIcon, Share2 } from 'lucide-react'; import { likePhoto } from '../services/photosApi'; import PhotoLightbox from './PhotoLightbox'; import { fetchEvent, type EventData } from '../services/eventApi'; import { useTranslation } from '../i18n/useTranslation'; import { sharePhotoLink } from '../lib/sharePhoto'; import { useToast } from '../components/ToastHost'; import { localizeTaskLabel } from '../lib/localizeTaskLabel'; const allowedGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth']; type GalleryPhoto = { id: number; likes_count?: number | null; created_at?: string | null; ingest_source?: string | null; session_id?: string | null; task_id?: number | null; task_title?: string | null; emotion_id?: number | null; emotion_name?: string | null; thumbnail_path?: string | null; file_path?: string | null; title?: string | null; uploader_name?: string | null; }; 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 { t, locale } = useTranslation(); const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '', locale); const [searchParams, setSearchParams] = useSearchParams(); const photoIdParam = searchParams.get('photoId'); const modeParam = searchParams.get('mode'); const [filter, setFilterState] = React.useState(() => parseGalleryFilter(modeParam)); const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState(null); const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false); const [event, setEvent] = useState(null); const [eventLoading, setEventLoading] = useState(true); const toast = useToast(); const [shareTargetId, setShareTargetId] = React.useState(null); const numberFormatter = React.useMemo(() => new Intl.NumberFormat(locale), [locale]); useEffect(() => { setFilterState(parseGalleryFilter(modeParam)); }, [modeParam]); const setFilter = React.useCallback((next: GalleryFilter) => { setFilterState(next); const params = new URLSearchParams(searchParams); params.set('mode', next); setSearchParams(params, { replace: true }); }, [searchParams, setSearchParams]); const typedPhotos = photos as GalleryPhoto[]; // Auto-open lightbox if photoId in query params useEffect(() => { if (photoIdParam && photos.length > 0 && currentPhotoIndex === null && !hasOpenedPhoto) { const index = typedPhotos.findIndex((photo) => photo.id === parseInt(photoIdParam, 10)); if (index !== -1) { setCurrentPhotoIndex(index); setHasOpenedPhoto(true); } } }, [typedPhotos, photos.length, photoIdParam, currentPhotoIndex, hasOpenedPhoto]); // Load event and package info useEffect(() => { if (!token) return; const loadEventData = async () => { try { setEventLoading(true); const eventData = await fetchEvent(token); setEvent(eventData); } 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(raw ? JSON.parse(raw) : []); } catch { return new Set(); } }, []); const list = React.useMemo(() => { let arr = typedPhotos.slice(); if (filter === 'popular') { arr.sort((a, b) => (b.likes_count ?? 0) - (a.likes_count ?? 0)); } else if (filter === 'mine') { arr = arr.filter((p) => myPhotoIds.has(p.id)); } else if (filter === 'photobooth') { arr = arr.filter((p) => p.ingest_source === 'photobooth'); arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()); } else { arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()); } return arr; }, [typedPhotos, filter, myPhotoIds]); const [liked, setLiked] = React.useState>(new Set()); const [counts, setCounts] = React.useState>({}); 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 (error) { console.warn('Failed to persist liked-photo-ids', error); } } catch (error) { console.warn('Like failed', error); const s = new Set(liked); s.delete(id); setLiked(s); } } async function onShare(photo: GalleryPhoto) { if (!token) return; setShareTargetId(photo.id); try { const localizedTask = localizeTaskLabel(photo.task_title ?? null, locale); const result = await sharePhotoLink({ token, photoId: photo.id, title: localizedTask ?? 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 (

{t('galleryPage.eventNotFound', 'Event nicht gefunden.')}

); } if (eventLoading) { return (

{t('galleryPage.loadingEvent', 'Lade Event-Info...')}

); } const newPhotosBadgeText = t('galleryPage.badge.newPhotos', { count: numberFormatter.format(newCount), }, `${newCount} neue Fotos`); const badgeEmphasisClass = newCount > 0 ? 'border border-pink-200 bg-pink-500/15 text-pink-600' : 'border border-transparent bg-muted text-muted-foreground'; return (

{t('galleryPage.title')}

{t('galleryPage.subtitle')}

{newCount > 0 ? ( ) : ( {newPhotosBadgeText} )}
{loading &&

{t('galleryPage.loading', 'Lade…')}

}
{list.map((p: GalleryPhoto) => { 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('galleryPage.photo.justNow', 'Gerade eben'); const likeCount = counts[p.id] ?? (p.likes_count || 0); const localizedTaskTitle = localizeTaskLabel(p.task_title ?? null, locale); const altSuffix = localizedTaskTitle ? t('galleryPage.photo.altTaskSuffix', { task: localizedTaskTitle }) : ''; const altText = t('galleryPage.photo.alt', { id: p.id, suffix: altSuffix }, `Foto ${p.id}${altSuffix}`); const openPhoto = () => { const index = list.findIndex((photo) => photo.id === p.id); setCurrentPhotoIndex(index >= 0 ? index : null); }; return (
{ 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" > {altText} { (e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg=='; }} loading="lazy" />
{localizedTaskTitle &&

{localizedTaskTitle}

}
{createdLabel} {p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}
); })}
{currentPhotoIndex !== null && list.length > 0 && ( setCurrentPhotoIndex(null)} onIndexChange={(index: number) => setCurrentPhotoIndex(index)} token={token} /> )} ); }