// @ts-nocheck 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 { motion } from 'framer-motion'; import { likePhoto } from '../services/photosApi'; import PhotoLightbox from './PhotoLightbox'; import { fetchEvent, type EventData } from '../services/eventApi'; import { useTranslation } from '../i18n/useTranslation'; import { useToast } from '../components/ToastHost'; import { localizeTaskLabel } from '../lib/localizeTaskLabel'; import { createPhotoShareLink } from '../services/photosApi'; import { cn } from '@/lib/utils'; import { useEventBranding } from '../context/EventBrandingContext'; import ShareSheet from '../components/ShareSheet'; import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion'; import PullToRefresh from '../components/PullToRefresh'; const allGalleryFilters: 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 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 { branding } = useEventBranding(); const { photos, loading, newCount, acknowledgeNew, refreshNow } = usePollGalleryDelta(token ?? '', locale); const [searchParams, setSearchParams] = useSearchParams(); const photoIdParam = searchParams.get('photoId'); const modeParam = searchParams.get('mode'); const radius = branding.buttons?.radius ?? 12; const buttonStyle = branding.buttons?.style ?? 'filled'; const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor; const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; const motionEnabled = !prefersReducedMotion(); const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST); const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP); const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE); const gridMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST); const [filter, setFilterState] = React.useState('latest'); 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]); const [shareSheet, setShareSheet] = React.useState<{ photo: GalleryPhoto | null; url: string | null; loading: boolean }>({ photo: null, url: null, loading: false, }); const typedPhotos = photos as GalleryPhoto[]; const showPhotoboothFilter = React.useMemo( () => Boolean(event?.photobooth_enabled) || typedPhotos.some((p) => p.ingest_source === 'photobooth'), [event?.photobooth_enabled, typedPhotos], ); const allowedGalleryFilters = React.useMemo( () => (showPhotoboothFilter ? allGalleryFilters : ['latest', 'popular', 'mine']), [showPhotoboothFilter], ); const parseGalleryFilter = React.useCallback( (value: string | null): GalleryFilter => allowedGalleryFilters.includes(value as GalleryFilter) ? (value as GalleryFilter) : 'latest', [allowedGalleryFilters], ); useEffect(() => { setFilterState(parseGalleryFilter(modeParam)); }, [modeParam, parseGalleryFilter]); const setFilter = React.useCallback((next: GalleryFilter) => { setFilterState(next); const params = new URLSearchParams(searchParams); params.set('mode', next); setSearchParams(params, { replace: true }); }, [searchParams, setSearchParams]); useEffect(() => { if (filter === 'photobooth' && !showPhotoboothFilter) { setFilter('latest'); } }, [filter, showPhotoboothFilter, setFilter]); // 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 const loadEventData = React.useCallback(async () => { if (!token) return; try { setEventLoading(true); const eventData = await fetchEvent(token); setEvent(eventData); } catch (err) { console.error('Failed to load event data', err); } finally { setEventLoading(false); } }, [token]); useEffect(() => { void loadEventData(); }, [loadEventData]); const handleRefresh = React.useCallback(async () => { await Promise.all([refreshNow(), loadEventData()]); acknowledgeNew(); }, [acknowledgeNew, loadEventData, refreshNow]); 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); } } const buildShareText = (fallback?: string) => { const eventName = event?.name ?? fallback ?? 'Fotospiel'; const base = t('share.shareText', 'Schau dir diesen Moment bei Fotospiel an.'); return `${eventName} – ${base}`; }; async function onShare(photo: GalleryPhoto) { if (!token) return; setShareSheet({ photo, url: null, loading: true }); setShareTargetId(photo.id); try { const url = await ensureShareUrl(photo); setShareSheet({ photo, url, loading: false }); } catch (error) { console.error('share failed', error); toast.push({ text: t('share.error', 'Teilen fehlgeschlagen'), type: 'error' }); setShareSheet({ photo: null, url: null, loading: false }); } finally { setShareTargetId(null); } } async function ensureShareUrl(photo: GalleryPhoto): Promise { if (!token) throw new Error('missing token'); const payload = await createPhotoShareLink(token, photo.id); return payload.url; } function shareNative(url?: string | null) { if (!shareSheet.photo || !url) return; const localizedTask = localizeTaskLabel(shareSheet.photo.task_title ?? null, locale); const data: ShareData = { title: localizedTask ?? event?.name ?? t('share.title', 'Geteiltes Foto'), text: buildShareText(), url, }; if (navigator.share && (!navigator.canShare || navigator.canShare(data))) { navigator.share(data).catch(() => { // user cancelled; no toast }); setShareSheet({ photo: null, url: null, loading: false }); return; } void copyLink(url); } function shareWhatsApp(url?: string) { if (!url) return; const text = `${buildShareText()} ${url}`; const waUrl = `https://wa.me/?text=${encodeURIComponent(text)}`; window.open(waUrl, '_blank', 'noopener'); setShareSheet({ photo: null, url: null, loading: false }); } function shareMessages(url?: string) { if (!url) return; const text = `${buildShareText()} ${url}`; const smsUrl = `sms:?&body=${encodeURIComponent(text)}`; window.open(smsUrl, '_blank', 'noopener'); setShareSheet({ photo: null, url: null, loading: false }); } async function copyLink(url?: string | null) { if (!url) return; try { await navigator.clipboard?.writeText(url); toast.push({ text: t('share.copySuccess', 'Link kopiert!') }); } catch { toast.push({ text: t('share.copyError', 'Link konnte nicht kopiert werden.'), type: 'error' }); } finally { setShareSheet({ photo: null, url: null, loading: false }); } } function closeShareSheet() { setShareSheet({ photo: null, url: null, loading: false }); } 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 border border-white/20 bg-gray-950 text-white shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400" style={{ borderRadius: radius }} {...fadeScaleMotion} > {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')}
); })} {list.length === 0 && Array.from({ length: 6 }).map((_, idx) => (
))} {currentPhotoIndex !== null && list.length > 0 && ( setCurrentPhotoIndex(null)} onIndexChange={(index: number) => setCurrentPhotoIndex(index)} token={token} eventName={event?.name ?? null} /> )} shareNative(shareSheet.url)} onShareWhatsApp={() => shareWhatsApp(shareSheet.url)} onShareMessages={() => shareMessages(shareSheet.url)} onCopyLink={() => copyLink(shareSheet.url)} radius={radius} bodyFont={bodyFont} headingFont={headingFont} /> ); }