import React, { useState, useEffect } from 'react'; import { useParams, useLocation, useNavigate } from 'react-router-dom'; import { Dialog, DialogContent } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Heart, ChevronLeft, ChevronRight, X, Share2, Download } from 'lucide-react'; import { likePhoto, createPhotoShareLink } from '../services/photosApi'; import { useTranslation } from '../i18n/useTranslation'; import { useToast } from '../components/ToastHost'; import ShareSheet from '../components/ShareSheet'; import { useEventBranding } from '../context/EventBrandingContext'; import { getDeviceId } from '../lib/device'; import { triggerHaptic } from '../lib/haptics'; import { useGesture } from '@use-gesture/react'; import { animated, to, useSpring } from '@react-spring/web'; type Photo = { id: number; file_path?: string; thumbnail_path?: string; likes_count?: number; created_at?: string; task_id?: number; task_title?: string; uploader_name?: string | null; }; type Task = { id: number; title: string }; interface Props { photos?: Photo[]; currentIndex?: number; onClose?: () => void; onIndexChange?: (index: number) => void; token?: string; eventName?: string | null; } export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexChange, token, eventName }: Props) { const params = useParams<{ token?: string; photoId?: string }>(); const location = useLocation(); const navigate = useNavigate(); const photoId = params.photoId; const eventToken = params.token || token; const { t, locale } = useTranslation(); const toast = useToast(); const { branding } = useEventBranding(); const [standalonePhoto, setStandalonePhoto] = useState(null); const [task, setTask] = useState(null); const [taskLoading, setTaskLoading] = useState(false); const [likes, setLikes] = useState(0); const [liked, setLiked] = useState(false); const [shareSheet, setShareSheet] = useState<{ url: string | null; loading: boolean }>({ url: null, loading: false, }); // Determine mode and photo const isStandalone = !photos || photos.length === 0; const currentPhotos = isStandalone ? (standalonePhoto ? [standalonePhoto] : []) : photos || []; const currentIndexVal = isStandalone ? 0 : (currentIndex || 0); const photo = currentPhotos[currentIndexVal]; // Fallback onClose for standalone const handleClose = onClose || (() => navigate(-1)); // Fetch single photo for standalone mode useEffect(() => { if (isStandalone && photoId && !standalonePhoto && eventToken) { const fetchPhoto = async () => { try { const res = await fetch(`/api/v1/photos/${photoId}?locale=${encodeURIComponent(locale)}`, { headers: { Accept: 'application/json', 'X-Locale': locale, }, }); if (res.ok) { const fetchedPhoto: Photo = await res.json(); setStandalonePhoto(fetchedPhoto); // Check state for initial photo if (location.state?.photo) { setStandalonePhoto(location.state.photo); } } else { toast.push({ text: t('lightbox.errors.notFound'), type: 'error' }); } } catch (err) { console.warn('Standalone photo load failed', err); toast.push({ text: t('lightbox.errors.loadFailed'), type: 'error' }); } }; fetchPhoto(); } }, [isStandalone, photoId, eventToken, standalonePhoto, location.state, t, locale, toast]); // Update likes when photo changes React.useEffect(() => { if (photo) { setLikes(photo.likes_count ?? 0); // Check if liked from localStorage try { const raw = localStorage.getItem('liked-photo-ids'); const likedIds = raw ? JSON.parse(raw) : []; setLiked(likedIds.includes(photo.id)); } catch { setLiked(false); } } }, [photo]); const radius = branding.buttons?.radius ?? 12; const bodyFont = branding.typography?.body ?? branding.fontFamily ?? null; const headingFont = branding.typography?.heading ?? branding.fontFamily ?? null; const zoomContainerRef = React.useRef(null); const zoomImageRef = React.useRef(null); const baseSizeRef = React.useRef({ width: 0, height: 0 }); const scaleRef = React.useRef(1); const lastTapRef = React.useRef(0); const [isZoomed, setIsZoomed] = React.useState(false); const [{ x, y, scale }, api] = useSpring(() => ({ x: 0, y: 0, scale: 1, config: { tension: 260, friction: 28 }, })); const updateBaseSize = React.useCallback(() => { if (!zoomImageRef.current) { return; } const rect = zoomImageRef.current.getBoundingClientRect(); baseSizeRef.current = { width: rect.width, height: rect.height }; }, []); React.useEffect(() => { updateBaseSize(); }, [photo?.id, updateBaseSize]); React.useEffect(() => { window.addEventListener('resize', updateBaseSize); return () => window.removeEventListener('resize', updateBaseSize); }, [updateBaseSize]); const clamp = React.useCallback((value: number, min: number, max: number) => { return Math.min(max, Math.max(min, value)); }, []); const getBounds = React.useCallback( (nextScale: number) => { const container = zoomContainerRef.current?.getBoundingClientRect(); const { width, height } = baseSizeRef.current; if (!container || !width || !height) { return { maxX: 0, maxY: 0 }; } const scaledWidth = width * nextScale; const scaledHeight = height * nextScale; const maxX = Math.max(0, (scaledWidth - container.width) / 2); const maxY = Math.max(0, (scaledHeight - container.height) / 2); return { maxX, maxY }; }, [] ); const resetZoom = React.useCallback(() => { scaleRef.current = 1; setIsZoomed(false); api.start({ x: 0, y: 0, scale: 1 }); }, [api]); React.useEffect(() => { resetZoom(); }, [photo?.id, resetZoom]); const toggleZoom = React.useCallback(() => { const nextScale = scaleRef.current > 1.01 ? 1 : 2; scaleRef.current = nextScale; setIsZoomed(nextScale > 1.01); api.start({ x: 0, y: 0, scale: nextScale }); }, [api]); const bind = useGesture( { onDrag: ({ down, movement: [mx, my], offset: [ox, oy], last, event }) => { if (event.cancelable) { event.preventDefault(); } const zoomed = scaleRef.current > 1.01; if (!zoomed) { api.start({ x: down ? mx : 0, y: 0, immediate: down }); if (last) { api.start({ x: 0, y: 0, immediate: false }); const threshold = 80; if (Math.abs(mx) > threshold) { if (mx > 0 && currentIndexVal > 0) { onIndexChange?.(currentIndexVal - 1); } else if (mx < 0 && currentIndexVal < currentPhotos.length - 1) { onIndexChange?.(currentIndexVal + 1); } } } return; } const { maxX, maxY } = getBounds(scaleRef.current); api.start({ x: clamp(ox, -maxX, maxX), y: clamp(oy, -maxY, maxY), immediate: down, }); }, onPinch: ({ offset: [nextScale], last, event }) => { if (event.cancelable) { event.preventDefault(); } const clampedScale = clamp(nextScale, 1, 3); scaleRef.current = clampedScale; setIsZoomed(clampedScale > 1.01); const { maxX, maxY } = getBounds(clampedScale); api.start({ scale: clampedScale, x: clamp(x.get(), -maxX, maxX), y: clamp(y.get(), -maxY, maxY), immediate: true, }); if (last && clampedScale <= 1.01) { resetZoom(); } }, }, { drag: { from: () => [x.get(), y.get()], filterTaps: true, threshold: 4, }, pinch: { scaleBounds: { min: 1, max: 3 }, rubberband: true, }, eventOptions: { passive: false }, } ); const handlePointerUp = (event: React.PointerEvent) => { if (event.pointerType !== 'touch') { return; } const now = Date.now(); if (now - lastTapRef.current < 280) { lastTapRef.current = 0; toggleZoom(); return; } lastTapRef.current = now; }; // Load task info if photo has task_id and event key is available React.useEffect(() => { if (!photo?.task_id || !eventToken) { setTask(null); setTaskLoading(false); return; } const taskId = photo.task_id; (async () => { setTaskLoading(true); try { const res = await fetch( `/api/v1/events/${encodeURIComponent(eventToken)}/tasks?locale=${encodeURIComponent(locale)}`, { headers: { Accept: 'application/json', 'X-Locale': locale, 'X-Device-Id': getDeviceId(), }, } ); if (res.ok) { const payload = (await res.json()) as unknown; const tasks = Array.isArray(payload) ? payload : Array.isArray((payload as any)?.data) ? (payload as any).data : Array.isArray((payload as any)?.tasks) ? (payload as any).tasks : []; const foundTask = (tasks as Task[]).find((t) => t.id === taskId); if (foundTask) { setTask({ id: foundTask.id, title: foundTask.title || t('lightbox.fallbackTitle').replace('{id}', `${taskId}`) }); } else { setTask({ id: taskId, title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`) }); } } else { setTask({ id: taskId, title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`) }); } } catch (error) { console.error('Failed to load task:', error); setTask({ id: taskId, title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`) }); } finally { setTaskLoading(false); } })(); }, [photo?.task_id, eventToken, t, locale]); async function onLike() { if (liked || !photo) return; setLiked(true); try { const count = await likePhoto(photo.id); setLikes(count); triggerHaptic('selection'); // Update localStorage try { const raw = localStorage.getItem('liked-photo-ids'); const arr: number[] = raw ? JSON.parse(raw) : []; if (!arr.includes(photo.id)) { localStorage.setItem('liked-photo-ids', JSON.stringify([...arr, photo.id])); } } catch (storageError) { console.warn('Failed to persist liked photo IDs', storageError); } } catch (error) { console.error('Like failed:', error); setLiked(false); } } const shareTitle = photo?.task_title ?? task?.title ?? t('share.title', 'Geteiltes Foto'); const shareText = t('share.shareText', { event: eventName ?? shareTitle ?? 'Fotospiel' }); const createdLabel = React.useMemo(() => { if (!photo?.created_at) return null; try { const date = new Date(photo.created_at); return date.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }); } catch { return null; } }, [photo?.created_at, locale]); const uploaderInitial = React.useMemo(() => { const name = photo?.uploader_name; if (!name) return 'G'; return (name.trim()[0] || 'G').toUpperCase(); }, [photo?.uploader_name]); const primaryColor = branding.primaryColor || '#0ea5e9'; const secondaryColor = branding.secondaryColor || '#6366f1'; async function openShareSheet() { if (!photo || !eventToken) return; setShareSheet({ url: null, loading: true }); try { const payload = await createPhotoShareLink(eventToken, photo.id); setShareSheet({ url: payload.url, loading: false }); } catch (error) { console.error('share failed', error); toast.push({ text: t('share.error', 'Teilen fehlgeschlagen'), type: 'error' }); setShareSheet({ url: null, loading: false }); } } function shareWhatsApp(url?: string | null) { if (!url) return; const waUrl = `https://wa.me/?text=${encodeURIComponent(`${shareText} ${url}`)}`; window.open(waUrl, '_blank', 'noopener'); setShareSheet({ url: null, loading: false }); } function shareMessages(url?: string | null) { if (!url) return; const smsUrl = `sms:?&body=${encodeURIComponent(`${shareText} ${url}`)}`; window.open(smsUrl, '_blank', 'noopener'); setShareSheet({ url: null, loading: false }); } function shareNative(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(() => {}); setShareSheet({ url: null, loading: false }); return; } void copyLink(url); } 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({ url: null, loading: false }); } } function closeShareSheet() { setShareSheet({ url: null, loading: false }); } function onOpenChange(open: boolean) { if (!open) handleClose(); } return (
{currentIndexVal + 1} / {currentPhotos.length}
{currentIndexVal > 0 && ( )} `translate3d(${xValue}px, ${yValue}px, 0) scale(${scaleValue})` ), }} > {t('lightbox.photoAlt') { console.error('Image load error:', e); (e.target as HTMLImageElement).style.display = 'none'; }} /> {currentIndexVal < currentPhotos.length - 1 && ( )}
{uploaderInitial}
{photo?.uploader_name ? (

{photo.uploader_name}

) : (

{t('galleryPage.photo.anonymous', 'Gast')}

)} {createdLabel ?

{createdLabel}

: null}
{task ? ( {t('lightbox.taskLabel')}: {task.title} ) : null}
{taskLoading && !task && (
{t('lightbox.loadingTask')}
)}
shareNative(shareSheet.url)} onShareWhatsApp={() => shareWhatsApp(shareSheet.url)} onShareMessages={() => shareMessages(shareSheet.url)} onCopyLink={() => copyLink(shareSheet.url)} radius={radius} bodyFont={bodyFont} headingFont={headingFont} />
); }