import React from 'react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; import { Camera, ChevronLeft, ChevronRight, Download, Heart, Loader2, Share2, Sparkles, Trash2, X } from 'lucide-react'; import AppShell from '../components/AppShell'; import PhotoFrameTile from '../components/PhotoFrameTile'; import ShareSheet from '../components/ShareSheet'; import AiMagicEditSheet from '../components/AiMagicEditSheet'; import { useEventData } from '../context/EventDataContext'; import { createPhotoShareLink, deletePhoto, fetchGallery, fetchPhoto, likePhoto, unlikePhoto } from '../services/photosApi'; import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta'; import { useGuestThemeVariant } from '../lib/guestTheme'; import { useTranslation } from '@/shared/guest/i18n/useTranslation'; import { useLocale } from '@/shared/guest/i18n/LocaleContext'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { buildEventPath } from '../lib/routes'; import { getBentoSurfaceTokens } from '../lib/bento'; import { usePollStats } from '../hooks/usePollStats'; import { pushGuestToast } from '../lib/toast'; import { GUEST_AI_MAGIC_EDITS_ENABLED } from '../lib/featureFlags'; type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth'; type GalleryTile = { id: number; imageUrl: string; fullUrl?: string | null; downloadUrl?: string | null; likes: number; createdAt?: string | null; ingestSource?: string | null; sessionId?: string | null; isMine?: boolean; taskId?: number | null; taskLabel?: string | null; emotion?: { name?: string | null; icon?: string | null; color?: string | null; } | null; }; type LightboxPhoto = { id: number; imageUrl: string; fullUrl?: string | null; downloadUrl?: string | null; likes: number; isMine?: boolean; taskId?: number | null; taskLabel?: string | null; emotion?: { name?: string | null; icon?: string | null; color?: string | null; } | null; }; function normalizeImageUrl(src?: string | null) { if (!src) { return ''; } if (/^https?:/i.test(src)) { return src; } let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/'); if (cleanPath.startsWith('api/')) { return `/${cleanPath}`; } if (!cleanPath.startsWith('storage/')) { cleanPath = `storage/${cleanPath}`; } return `/${cleanPath}`.replace(/\/+/g, '/'); } function readMyPhotoIds(): Set { try { const raw = localStorage.getItem('my-photo-ids'); const parsed = raw ? JSON.parse(raw) : []; if (!Array.isArray(parsed)) { return new Set(); } const ids = parsed.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0); return new Set(ids); } catch { return new Set(); } } export default function GalleryScreen() { const { token, event } = useEventData(); const { t } = useTranslation(); const { locale } = useLocale(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const { isDark } = useGuestThemeVariant(); const bentoSurface = getBentoSurfaceTokens(isDark); const cardShadow = bentoSurface.shadow; const hardShadow = isDark ? '0 18px 0 rgba(2, 6, 23, 0.55), 0 32px 40px rgba(2, 6, 23, 0.55)' : '0 18px 0 rgba(15, 23, 42, 0.22), 0 30px 36px rgba(15, 23, 42, 0.2)'; const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'; const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'; const [photos, setPhotos] = React.useState([]); const [loading, setLoading] = React.useState(false); const { data: delta } = usePollGalleryDelta(token ?? null, { locale }); const { stats } = usePollStats(token ?? null, 12000); const [filter, setFilter] = React.useState('latest'); const uploadPath = React.useMemo(() => buildEventPath(token ?? null, '/upload'), [token]); const numberFormatter = React.useMemo(() => new Intl.NumberFormat(locale), [locale]); const [lightboxPhoto, setLightboxPhoto] = React.useState(null); const [lightboxLoading, setLightboxLoading] = React.useState(false); const [lightboxError, setLightboxError] = React.useState<'notFound' | 'loadFailed' | null>(null); const [likesById, setLikesById] = React.useState>({}); const [shareSheet, setShareSheet] = React.useState<{ url: string | null; loading: boolean }>({ url: null, loading: false, }); const [aiMagicEditOpen, setAiMagicEditOpen] = React.useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false); const [deleteBusy, setDeleteBusy] = React.useState(false); const [deleteConfirmMounted, setDeleteConfirmMounted] = React.useState(false); const [deleteConfirmVisible, setDeleteConfirmVisible] = React.useState(false); const [likedIds, setLikedIds] = React.useState>(new Set()); const touchStartX = React.useRef(null); const fallbackAttemptedRef = React.useRef(false); const pendingNotFoundRef = React.useRef(false); const photosRef = React.useRef([]); const galleryLoadingRef = React.useRef(Boolean(token)); const transitionDirectionRef = React.useRef<'next' | 'prev'>('next'); const lastLightboxPhotoRef = React.useRef(null); const [lightboxTransition, setLightboxTransition] = React.useState<{ from: LightboxPhoto | null; to: LightboxPhoto | null; direction: 'next' | 'prev'; active: boolean; }>({ from: null, to: null, direction: 'next', active: false }); const [lightboxMounted, setLightboxMounted] = React.useState(false); const [lightboxVisible, setLightboxVisible] = React.useState(false); React.useEffect(() => { if (deleteConfirmOpen) { setDeleteConfirmVisible(false); setDeleteConfirmMounted(true); let frame1 = 0; let frame2 = 0; frame1 = window.requestAnimationFrame(() => { frame2 = window.requestAnimationFrame(() => setDeleteConfirmVisible(true)); }); return () => { window.cancelAnimationFrame(frame1); window.cancelAnimationFrame(frame2); }; } setDeleteConfirmVisible(false); const timer = window.setTimeout(() => setDeleteConfirmMounted(false), 220); return () => window.clearTimeout(timer); }, [deleteConfirmOpen]); React.useEffect(() => { if (!token) { setPhotos([]); galleryLoadingRef.current = false; return; } let active = true; setLoading(true); galleryLoadingRef.current = true; fetchGallery(token, { limit: 18, locale }) .then((response) => { if (!active) return; const list = Array.isArray(response.data) ? response.data : []; const mapped = list .map((photo) => { const record = photo as Record; const id = Number(record.id ?? 0); const likesCount = typeof record.likes_count === 'number' ? record.likes_count : 0; const fullUrl = normalizeImageUrl( (record.full_url as string | null | undefined) ?? (record.file_path as string | null | undefined) ?? (record.url as string | null | undefined) ?? (record.image_url as string | null | undefined) ); const imageUrl = normalizeImageUrl( (record.thumbnail_url as string | null | undefined) ?? (record.thumbnail_path as string | null | undefined) ?? fullUrl ?? (record.url as string | null | undefined) ?? (record.image_url as string | null | undefined) ); const downloadUrl = normalizeImageUrl( (record.download_url as string | null | undefined) ?? fullUrl ); const rawTaskId = Number(record.task_id ?? record.taskId ?? 0); const taskId = Number.isFinite(rawTaskId) && rawTaskId > 0 ? rawTaskId : null; const taskLabel = typeof record.task_title === 'string' ? record.task_title : typeof record.task_name === 'string' ? record.task_name : typeof record.task === 'string' ? record.task : typeof record.task_label === 'string' ? record.task_label : null; const rawEmotion = (record.emotion as Record | null) ?? null; const rawIsMine = record.is_mine ?? record.isMine; const isMine = rawIsMine === true || rawIsMine === 1 || rawIsMine === '1'; const emotionName = typeof rawEmotion?.name === 'string' ? rawEmotion.name : typeof record.emotion_name === 'string' ? record.emotion_name : null; const emotionIcon = typeof rawEmotion?.icon === 'string' ? rawEmotion.icon : typeof rawEmotion?.emoji === 'string' ? rawEmotion.emoji : typeof record.emotion_icon === 'string' ? record.emotion_icon : typeof record.emotion_emoji === 'string' ? record.emotion_emoji : null; const emotionColor = typeof rawEmotion?.color === 'string' ? rawEmotion.color : typeof record.emotion_color === 'string' ? record.emotion_color : null; const emotion = emotionName || emotionIcon || emotionColor ? { name: emotionName, icon: emotionIcon, color: emotionColor } : null; return { id, imageUrl, fullUrl: fullUrl || null, downloadUrl: downloadUrl || null, likes: likesCount, createdAt: typeof record.created_at === 'string' ? record.created_at : null, ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null, sessionId: typeof record.session_id === 'string' ? record.session_id : null, isMine, taskId, taskLabel, emotion, }; }) .filter((item) => item.id && item.imageUrl); setPhotos(mapped); }) .catch((error) => { console.error('Failed to load gallery', error); if (active) { setPhotos([]); } }) .finally(() => { if (active) { setLoading(false); } galleryLoadingRef.current = false; }); return () => { active = false; }; }, [token, locale]); React.useEffect(() => { photosRef.current = photos; }, [photos]); const [myPhotoIds, setMyPhotoIds] = React.useState>(() => readMyPhotoIds()); React.useEffect(() => { setMyPhotoIds(readMyPhotoIds()); }, [token]); const filteredPhotos = React.useMemo(() => { let list = photos.slice(); if (filter === 'popular') { list.sort((a, b) => (b.likes ?? 0) - (a.likes ?? 0)); } else if (filter === 'mine') { list = list.filter((photo) => Boolean(photo.isMine) || myPhotoIds.has(photo.id)); } else if (filter === 'photobooth') { list = list.filter((photo) => photo.ingestSource === 'photobooth'); list.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime()); } else { list.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime()); } return list; }, [filter, myPhotoIds, photos]); const displayPhotos = filteredPhotos; const leftColumn = displayPhotos.filter((_, index) => index % 2 === 0); const rightColumn = displayPhotos.filter((_, index) => index % 2 === 1); const isEmpty = !loading && displayPhotos.length === 0; const isSingle = !loading && displayPhotos.length === 1; const selectedPhotoId = Number(searchParams.get('photo') ?? 0); const lightboxIndex = React.useMemo(() => { if (!selectedPhotoId) { return -1; } return displayPhotos.findIndex((item) => item.id === selectedPhotoId); }, [displayPhotos, selectedPhotoId]); const lightboxSelected = lightboxIndex >= 0 ? displayPhotos[lightboxIndex] : null; const lightboxOpen = Boolean(selectedPhotoId); const canDelete = Boolean(lightboxPhoto && (lightboxPhoto.isMine || myPhotoIds.has(lightboxPhoto.id))); const hasAiStylingAccess = GUEST_AI_MAGIC_EDITS_ENABLED && Boolean(event?.capabilities?.ai_styling); React.useEffect(() => { if (filter === 'photobooth' && !photos.some((photo) => photo.ingestSource === 'photobooth')) { setFilter('latest'); } }, [filter, photos]); React.useEffect(() => { fallbackAttemptedRef.current = false; }, [selectedPhotoId]); React.useEffect(() => { if (!lightboxOpen || !selectedPhotoId) { return; } if (lightboxIndex >= 0) { return; } if (filter !== 'latest' && !fallbackAttemptedRef.current) { fallbackAttemptedRef.current = true; setFilter('latest'); } }, [filter, lightboxIndex, lightboxOpen, selectedPhotoId]); const newUploads = React.useMemo(() => { if (delta.photos.length === 0) { return 0; } const existing = new Set(photos.map((item) => item.id)); return delta.photos.reduce((count, photo) => { const id = Number((photo as Record).id ?? 0); if (id && !existing.has(id)) { return count + 1; } return count; }, 0); }, [delta.photos, photos]); const openLightbox = React.useCallback( (photoId: number) => { if (!token) return; const next = new URLSearchParams(searchParams); next.set('photo', String(photoId)); setSearchParams(next, { replace: false }); }, [searchParams, setSearchParams, token] ); const closeLightbox = React.useCallback(() => { setAiMagicEditOpen(false); const next = new URLSearchParams(searchParams); next.delete('photo'); setSearchParams(next, { replace: true }); }, [searchParams, setSearchParams]); const removeMyPhotoId = React.useCallback((photoId: number) => { setMyPhotoIds((prev) => { const next = new Set(prev); next.delete(photoId); return next; }); try { const raw = localStorage.getItem('my-photo-ids'); const parsed = raw ? JSON.parse(raw) : []; if (Array.isArray(parsed)) { const next = parsed.filter((value) => Number(value) !== photoId); localStorage.setItem('my-photo-ids', JSON.stringify(next)); } } catch (error) { console.warn('Failed to update my-photo-ids', error); } }, []); React.useEffect(() => { if (delta.photos.length === 0) { return; } setPhotos((prev) => { const existing = new Set(prev.map((item) => item.id)); const mapped = delta.photos .map((photo) => { const record = photo as Record; const id = Number(record.id ?? 0); const likesCount = typeof record.likes_count === 'number' ? record.likes_count : 0; const fullUrl = normalizeImageUrl( (record.full_url as string | null | undefined) ?? (record.file_path as string | null | undefined) ?? (record.url as string | null | undefined) ?? (record.image_url as string | null | undefined) ); const imageUrl = normalizeImageUrl( (record.thumbnail_url as string | null | undefined) ?? (record.thumbnail_path as string | null | undefined) ?? fullUrl ?? (record.url as string | null | undefined) ?? (record.image_url as string | null | undefined) ); const downloadUrl = normalizeImageUrl( (record.download_url as string | null | undefined) ?? fullUrl ); if (!id || !imageUrl || existing.has(id)) { return null; } const rawIsMine = record.is_mine ?? record.isMine; const isMine = rawIsMine === true || rawIsMine === 1 || rawIsMine === '1'; return { id, imageUrl, fullUrl: fullUrl || null, downloadUrl: downloadUrl || null, likes: likesCount, createdAt: typeof record.created_at === 'string' ? record.created_at : null, ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null, sessionId: typeof record.session_id === 'string' ? record.session_id : null, isMine, } satisfies GalleryTile; }) .filter(Boolean) as GalleryTile[]; if (mapped.length === 0) { return prev; } return [...mapped, ...prev]; }); }, [delta.photos]); const heroStatsLine = t( 'galleryPage.hero.stats', { photoCount: numberFormatter.format(photos.length), likeCount: numberFormatter.format(stats.likesCount ?? 0), guestCount: numberFormatter.format(stats.onlineGuests || stats.guestCount || 0), }, `${numberFormatter.format(photos.length)} Fotos · ${numberFormatter.format(stats.likesCount ?? 0)} ❤️ · ${numberFormatter.format(stats.onlineGuests || stats.guestCount || 0)} Gäste online` ); React.useEffect(() => { setLikesById((prev) => { const next = { ...prev }; for (const photo of photos) { if (next[photo.id] === undefined) { next[photo.id] = photo.likes; } } return next; }); }, [photos]); React.useEffect(() => { if (!lightboxOpen) { setLightboxPhoto(null); setLightboxLoading(false); setLightboxError(null); pendingNotFoundRef.current = false; setDeleteConfirmOpen(false); setDeleteBusy(false); return; } const seed = lightboxSelected ? { id: lightboxSelected.id, imageUrl: lightboxSelected.imageUrl, fullUrl: lightboxSelected.fullUrl ?? null, downloadUrl: lightboxSelected.downloadUrl ?? null, likes: lightboxSelected.likes, isMine: lightboxSelected.isMine, taskId: lightboxSelected.taskId ?? null, taskLabel: lightboxSelected.taskLabel ?? null, emotion: lightboxSelected.emotion ?? null, } : null; if (seed) { setLightboxPhoto(seed); } const hasSeed = Boolean(seed); if (hasSeed) { setLightboxLoading(false); return; } let active = true; const maxRetryMs = 10_000; const retryDelayMs = 1500; let timedOut = false; const timeoutId = window.setTimeout(() => { timedOut = true; }, maxRetryMs); setLightboxLoading(true); setLightboxError(null); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const loadPhoto = async () => { let lastError: unknown = null; while (active && !timedOut) { try { const photo = await fetchPhoto(selectedPhotoId, locale); if (!active) return; if (photo) { const mapped = mapFullPhoto(photo as Record); if (mapped) { setLightboxPhoto(mapped); setLikesById((prev) => ({ ...prev, [mapped.id]: mapped.likes })); setLightboxLoading(false); return; } } lastError = { status: 404 }; } catch (error) { console.error('Lightbox photo load failed', error); lastError = error; } if (!active || timedOut) break; await sleep(retryDelayMs); } if (!active) return; const status = (lastError as { status?: number } | null)?.status; setLightboxError(status === 404 ? 'notFound' : 'loadFailed'); setLightboxLoading(false); }; void loadPhoto(); return () => { active = false; window.clearTimeout(timeoutId); }; }, [lightboxOpen, lightboxSelected, locale, selectedPhotoId]); React.useLayoutEffect(() => { if (!lightboxPhoto) { lastLightboxPhotoRef.current = null; setLightboxTransition({ from: null, to: null, direction: 'next', active: false }); return; } const previous = lastLightboxPhotoRef.current; if (!previous || previous.id === lightboxPhoto.id) { lastLightboxPhotoRef.current = lightboxPhoto; setLightboxTransition({ from: null, to: lightboxPhoto, direction: transitionDirectionRef.current, active: false }); return; } const direction = transitionDirectionRef.current; setLightboxTransition({ from: previous, to: lightboxPhoto, direction, active: false }); const raf = window.requestAnimationFrame(() => { setLightboxTransition((state) => ({ ...state, active: true })); }); const timeout = window.setTimeout(() => { setLightboxTransition({ from: null, to: lightboxPhoto, direction, active: false }); }, 420); lastLightboxPhotoRef.current = lightboxPhoto; return () => { window.cancelAnimationFrame(raf); window.clearTimeout(timeout); }; }, [lightboxPhoto]); React.useEffect(() => { if (!lightboxOpen) { document.body.style.overflow = ''; return; } document.body.style.overflow = 'hidden'; const handleKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { closeLightbox(); } }; window.addEventListener('keydown', handleKey); return () => { document.body.style.overflow = ''; window.removeEventListener('keydown', handleKey); }; }, [lightboxOpen]); React.useEffect(() => { if (lightboxOpen) { setLightboxMounted(true); const raf = window.requestAnimationFrame(() => { setLightboxVisible(true); }); return () => window.cancelAnimationFrame(raf); } setLightboxVisible(false); const timeout = window.setTimeout(() => { setLightboxMounted(false); }, 240); return () => window.clearTimeout(timeout); }, [lightboxOpen]); React.useEffect(() => { if (!pendingNotFoundRef.current) return; if (loading || galleryLoadingRef.current) return; if (lightboxSelected || photosRef.current.some((photo) => photo.id === selectedPhotoId)) { pendingNotFoundRef.current = false; return; } pendingNotFoundRef.current = false; setLightboxError('notFound'); }, [lightboxSelected, loading]); React.useEffect(() => { if (!lightboxOpen || !lightboxError) { return; } pushGuestToast({ text: lightboxError === 'notFound' ? t('lightbox.errors.notFound', 'Photo not found') : t('lightbox.errors.loadFailed', 'Failed to load photo'), type: 'warning', }); closeLightbox(); }, [closeLightbox, lightboxError, lightboxOpen, t]); const goPrev = React.useCallback(() => { if (lightboxIndex <= 0) return; const prevId = displayPhotos[lightboxIndex - 1]?.id; if (prevId) { transitionDirectionRef.current = 'prev'; openLightbox(prevId); } }, [displayPhotos, lightboxIndex, openLightbox]); const goNext = React.useCallback(() => { if (lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1) return; const nextId = displayPhotos[lightboxIndex + 1]?.id; if (nextId) { transitionDirectionRef.current = 'next'; openLightbox(nextId); } }, [displayPhotos, lightboxIndex, openLightbox]); const handleLike = React.useCallback(async () => { if (!lightboxPhoto) return; const isLiked = likedIds.has(lightboxPhoto.id); const current = likesById[lightboxPhoto.id] ?? lightboxPhoto.likes; const nextCount = Math.max(0, current + (isLiked ? -1 : 1)); setLikedIds((prev) => { const next = new Set(prev); if (isLiked) { next.delete(lightboxPhoto.id); } else { next.add(lightboxPhoto.id); } return next; }); setLikesById((prev) => ({ ...prev, [lightboxPhoto.id]: nextCount })); try { const count = isLiked ? await unlikePhoto(lightboxPhoto.id) : await likePhoto(lightboxPhoto.id); setLikesById((prev) => ({ ...prev, [lightboxPhoto.id]: count })); } catch (error) { console.error('Like failed', error); setLikedIds((prev) => { const next = new Set(prev); if (isLiked) { next.add(lightboxPhoto.id); } else { next.delete(lightboxPhoto.id); } return next; }); setLikesById((prev) => ({ ...prev, [lightboxPhoto.id]: current })); } }, [lightboxPhoto, likedIds, likesById]); const handleDelete = React.useCallback(async () => { if (!lightboxPhoto || !token || deleteBusy) return; setDeleteBusy(true); try { await deletePhoto(token, lightboxPhoto.id); setPhotos((prev) => prev.filter((photo) => photo.id !== lightboxPhoto.id)); setLikesById((prev) => { const next = { ...prev }; delete next[lightboxPhoto.id]; return next; }); setLikedIds((prev) => { const next = new Set(prev); next.delete(lightboxPhoto.id); return next; }); removeMyPhotoId(lightboxPhoto.id); setDeleteConfirmOpen(false); setDeleteBusy(false); closeLightbox(); pushGuestToast({ text: t('galleryPage.lightbox.deletedToast', 'Photo deleted.'), type: 'success', }); } catch (error) { console.error('Failed to delete photo', error); setDeleteBusy(false); pushGuestToast({ text: t('galleryPage.lightbox.deleteFailed', 'Photo could not be deleted.'), type: 'error', }); } }, [closeLightbox, deleteBusy, lightboxPhoto, removeMyPhotoId, t, token]); const shareTitle = event?.name ?? t('share.title', 'Shared photo'); const shareText = t('share.shareText', 'Check out this moment on Fotospiel.'); const openShareSheet = React.useCallback(async () => { if (!lightboxPhoto || !token) return; setShareSheet({ url: null, loading: true }); try { const payload = await createPhotoShareLink(token, lightboxPhoto.id); const url = payload?.url ?? null; setShareSheet({ url, loading: false }); } catch (error) { console.error('Share failed', error); pushGuestToast({ text: t('share.error', 'Share failed'), type: 'error' }); setShareSheet({ url: null, loading: false }); } }, [lightboxPhoto, t, token]); const closeShareSheet = React.useCallback(() => { setShareSheet({ url: null, loading: false }); }, []); const shareWhatsApp = React.useCallback( (url?: string | null) => { if (!url) return; const waUrl = `https://wa.me/?text=${encodeURIComponent(`${shareText} ${url}`)}`; window.open(waUrl, '_blank', 'noopener'); closeShareSheet(); }, [closeShareSheet, shareText] ); const shareMessages = React.useCallback( (url?: string | null) => { if (!url) return; const smsUrl = `sms:?&body=${encodeURIComponent(`${shareText} ${url}`)}`; window.open(smsUrl, '_blank', 'noopener'); closeShareSheet(); }, [closeShareSheet, shareText] ); const copyLink = React.useCallback( async (url?: string | null) => { if (!url) return; try { await navigator.clipboard?.writeText(url); pushGuestToast({ text: t('share.copySuccess', 'Link copied!') }); } catch (error) { console.error('Copy failed', error); pushGuestToast({ text: t('share.copyError', 'Link could not be copied.'), type: 'error' }); } finally { closeShareSheet(); } }, [closeShareSheet, t] ); const shareNative = React.useCallback( (url?: string | null) => { if (!url) return; const data: ShareData = { title: shareTitle, text: shareText, url, }; if (navigator.share && (!navigator.canShare || navigator.canShare(data))) { navigator.share(data).catch(() => undefined); closeShareSheet(); return; } void copyLink(url); }, [closeShareSheet, copyLink, shareText, shareTitle] ); const downloadPhoto = React.useCallback((photo?: LightboxPhoto | null) => { const url = photo?.downloadUrl ?? photo?.fullUrl ?? photo?.imageUrl ?? null; if (!url) return; const link = document.createElement('a'); link.href = url; link.download = `photo-${photo.id}.jpg`; link.rel = 'noreferrer'; document.body.appendChild(link); link.click(); document.body.removeChild(link); }, []); const openAiMagicEdit = React.useCallback(() => { if (!lightboxPhoto || !hasAiStylingAccess) { return; } setAiMagicEditOpen(true); }, [hasAiStylingAccess, lightboxPhoto]); const handleTouchStart = (event: React.TouchEvent) => { touchStartX.current = event.touches[0]?.clientX ?? null; }; const handleTouchEnd = (event: React.TouchEvent) => { if (touchStartX.current === null) { return; } const endX = event.changedTouches[0]?.clientX ?? null; if (endX === null) { touchStartX.current = null; return; } const delta = endX - touchStartX.current; touchStartX.current = null; if (Math.abs(delta) < 60) { return; } if (delta > 0) { transitionDirectionRef.current = 'prev'; goPrev(); return; } transitionDirectionRef.current = 'next'; goNext(); }; return ( {t('galleryPage.hero.label', 'Live-Galerie')} {event?.name ?? t('galleryPage.hero.eventFallback', 'Euer Event')} {newUploads > 0 ? ( {t('galleryPage.feed.newUploads', { count: newUploads }, '{count} neue Uploads sind da.')} ) : null} {heroStatsLine} {( [ { value: 'latest', label: t('galleryPage.filters.latest', 'Newest') }, { value: 'popular', label: t('galleryPage.filters.popular', 'Popular') }, { value: 'mine', label: t('galleryPage.filters.mine', 'My photos') }, photos.some((photo) => photo.ingestSource === 'photobooth') ? { value: 'photobooth', label: t('galleryPage.filters.photobooth', 'Photo booth') } : null, ].filter(Boolean) as Array<{ value: GalleryFilter; label: string }> ).map((chip) => ( ))} {isEmpty ? ( {t('galleryPage.emptyTitle', 'Noch keine Fotos')} {t('galleryPage.emptyDescription', 'Lade das erste Foto hoch und starte die Galerie.')} ) : isSingle ? ( ) : ( {(loading ? Array.from({ length: 5 }, (_, index) => index) : leftColumn).map((tile, index) => { if (typeof tile === 'number') { return ; } const altText = t('galleryPage.photo.alt', { id: tile.id, suffix: '' }, `Foto ${tile.id}`); return ( ); })} {(loading ? Array.from({ length: 5 }, (_, index) => index) : rightColumn).map((tile, index) => { if (typeof tile === 'number') { return ; } const altText = t('galleryPage.photo.alt', { id: tile.id, suffix: '' }, `Foto ${tile.id}`); return ( ); })} {!loading && rightColumn.length === 0 ? ( ) : null} )} {lightboxOpen || lightboxMounted ? ( {lightboxIndex >= 0 ? `${lightboxIndex + 1} / ${displayPhotos.length}` : ''} {lightboxPhoto ? ( {likesById[lightboxPhoto.id] ?? lightboxPhoto.likes} ) : null} {lightboxPhoto ? ( ) : null} {lightboxPhoto ? ( ) : null} {lightboxPhoto && hasAiStylingAccess ? ( ) : null} {lightboxPhoto && canDelete ? ( ) : null} {deleteConfirmMounted ? ( {t('galleryPage.lightbox.deleteTitle', 'Foto löschen?')} {t( 'galleryPage.lightbox.deleteDescription', 'Das Foto wird aus der Galerie entfernt und kann nicht wiederhergestellt werden.' )} ) : null} { if (!open) { closeShareSheet(); } }} photoId={lightboxPhoto?.id} eventName={event?.name ?? null} url={shareSheet.url} loading={shareSheet.loading} onShareNative={() => shareNative(shareSheet.url)} onShareWhatsApp={() => shareWhatsApp(shareSheet.url)} onShareMessages={() => shareMessages(shareSheet.url)} onCopyLink={() => copyLink(shareSheet.url)} variant="inline" /> {hasAiStylingAccess ? ( ) : null} ) : null} ); } function mapFullPhoto(photo: Record): LightboxPhoto | null { const id = Number(photo.id ?? 0); if (!id) return null; const fullUrl = normalizeImageUrl( (photo.full_url as string | null | undefined) ?? (photo.file_path as string | null | undefined) ?? (photo.url as string | null | undefined) ?? (photo.image_url as string | null | undefined) ); const imageUrl = normalizeImageUrl( fullUrl ?? (photo.thumbnail_url as string | null | undefined) ?? (photo.thumbnail_path as string | null | undefined) ?? (photo.url as string | null | undefined) ?? (photo.image_url as string | null | undefined) ); const downloadUrl = normalizeImageUrl( (photo.download_url as string | null | undefined) ?? fullUrl ); if (!imageUrl) return null; const taskLabel = typeof photo.task_title === 'string' ? photo.task_title : typeof photo.task_name === 'string' ? photo.task_name : typeof photo.task === 'string' ? photo.task : typeof photo.task_label === 'string' ? photo.task_label : null; const rawTaskId = Number(photo.task_id ?? photo.taskId ?? 0); const taskId = Number.isFinite(rawTaskId) && rawTaskId > 0 ? rawTaskId : null; const rawEmotion = (photo.emotion as Record | null) ?? null; const emotionName = typeof rawEmotion?.name === 'string' ? rawEmotion.name : typeof photo.emotion_name === 'string' ? photo.emotion_name : null; const emotionIcon = typeof rawEmotion?.icon === 'string' ? rawEmotion.icon : typeof rawEmotion?.emoji === 'string' ? rawEmotion.emoji : typeof photo.emotion_icon === 'string' ? photo.emotion_icon : typeof photo.emotion_emoji === 'string' ? photo.emotion_emoji : null; const emotionColor = typeof rawEmotion?.color === 'string' ? rawEmotion.color : typeof photo.emotion_color === 'string' ? photo.emotion_color : null; const emotion = emotionName || emotionIcon || emotionColor ? { name: emotionName, icon: emotionIcon, color: emotionColor } : null; const rawIsMine = photo.is_mine ?? photo.isMine; const isMine = rawIsMine === true || rawIsMine === 1 || rawIsMine === '1'; return { id, imageUrl, fullUrl: fullUrl || null, downloadUrl: downloadUrl || null, likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0, isMine, taskId, taskLabel, emotion, }; }