import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; import { ArrowLeft, ChevronLeft, ChevronRight, Download, Heart, Share2 } from 'lucide-react'; import { useGesture } from '@use-gesture/react'; import { animated, to, useSpring } from '@react-spring/web'; import AppShell from '../components/AppShell'; import SurfaceCard from '../components/SurfaceCard'; import ShareSheet from '../components/ShareSheet'; import { useEventData } from '../context/EventDataContext'; import { fetchGallery, fetchPhoto, likePhoto, createPhotoShareLink } from '../services/photosApi'; import { useTranslation } from '@/guest/i18n/useTranslation'; import { useLocale } from '@/guest/i18n/LocaleContext'; import { useGuestThemeVariant } from '../lib/guestTheme'; import { buildEventPath } from '../lib/routes'; import { pushGuestToast } from '../lib/toast'; type LightboxPhoto = { id: number; imageUrl: string; likes: number; createdAt?: string | null; ingestSource?: string | 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('storage/')) { cleanPath = `storage/${cleanPath}`; } return `/${cleanPath}`.replace(/\/+/g, '/'); } function mapPhoto(photo: Record): LightboxPhoto | null { const id = Number(photo.id ?? 0); if (!id) return null; const imageUrl = normalizeImageUrl( (photo.full_url as string | null | undefined) ?? (photo.file_path as string | null | undefined) ?? (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) ); if (!imageUrl) return null; return { id, imageUrl, likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0, createdAt: typeof photo.created_at === 'string' ? photo.created_at : null, ingestSource: typeof photo.ingest_source === 'string' ? photo.ingest_source : null, }; } export default function PhotoLightboxScreen() { const { token, event } = useEventData(); const { photoId } = useParams<{ photoId: string }>(); const navigate = useNavigate(); const { t } = useTranslation(); const { locale } = useLocale(); const { isDark } = useGuestThemeVariant(); const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'; 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 groupBackground = isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(15, 23, 42, 0.04)'; const [photos, setPhotos] = React.useState([]); const [selectedIndex, setSelectedIndex] = React.useState(null); const [cursor, setCursor] = React.useState(null); const [loading, setLoading] = React.useState(false); const [loadingMore, setLoadingMore] = React.useState(false); const [error, setError] = React.useState(null); const [likes, setLikes] = React.useState>({}); const [shareSheet, setShareSheet] = React.useState<{ url: string | null; loading: boolean }>({ url: null, loading: false, }); 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 selected = selectedIndex !== null ? photos[selectedIndex] : null; const loadPage = React.useCallback( async (nextCursor?: string | null, replace = false) => { if (!token) return { items: [], nextCursor: null }; const response = await fetchGallery(token, { cursor: nextCursor ?? undefined, limit: 28, locale }); const mapped = (response.data ?? []) .map((photo) => mapPhoto(photo as Record)) .filter(Boolean) as LightboxPhoto[]; setCursor(response.next_cursor ?? null); setPhotos((prev) => (replace ? mapped : [...prev, ...mapped])); setLikes((prev) => { const next = { ...prev }; for (const item of mapped) { if (next[item.id] === undefined) { next[item.id] = item.likes; } } return next; }); return { items: mapped, nextCursor: response.next_cursor ?? null }; }, [locale, token] ); React.useEffect(() => { if (!token || !photoId) { setError(t('lightbox.errors.notFound', 'Photo not found')); return; } let active = true; const targetId = Number(photoId); const init = async () => { setLoading(true); setError(null); try { let foundIndex = -1; let nextCursor: string | null = null; let combined: LightboxPhoto[] = []; for (let page = 0; page < 5; page += 1) { const response = await fetchGallery(token, { cursor: nextCursor ?? undefined, limit: 28, locale, }); const mapped = (response.data ?? []) .map((photo) => mapPhoto(photo as Record)) .filter(Boolean) as LightboxPhoto[]; combined = [...combined, ...mapped]; nextCursor = response.next_cursor ?? null; foundIndex = combined.findIndex((item) => item.id === targetId); if (foundIndex >= 0 || !nextCursor) { break; } } if (!active) return; if (combined.length > 0) { setPhotos(combined); setCursor(nextCursor); setLikes((prev) => { const next = { ...prev }; for (const item of combined) { if (next[item.id] === undefined) { next[item.id] = item.likes; } } return next; }); } if (foundIndex >= 0) { setSelectedIndex(foundIndex); return; } const single = await fetchPhoto(targetId, locale); if (!active) return; const mappedSingle = single ? mapPhoto(single as Record) : null; if (mappedSingle) { setPhotos([mappedSingle]); setSelectedIndex(0); return; } setError(t('lightbox.errors.notFound', 'Photo not found')); } catch (err) { if (!active) return; console.error('Photo lightbox load failed', err); setError(t('lightbox.errors.loadFailed', 'Failed to load photo')); } finally { if (active) setLoading(false); } }; init(); return () => { active = false; }; }, [photoId, token, locale, t]); const updateBaseSize = React.useCallback(() => { if (!zoomImageRef.current) { return; } const rect = zoomImageRef.current.getBoundingClientRect(); baseSizeRef.current = { width: rect.width, height: rect.height }; }, []); React.useEffect(() => { updateBaseSize(); }, [selected?.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(); }, [selected?.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 goPrev = React.useCallback(() => { if (selectedIndex === null || selectedIndex <= 0) return; setSelectedIndex(selectedIndex - 1); }, [selectedIndex]); const goNext = React.useCallback(async () => { if (selectedIndex === null) return; if (selectedIndex < photos.length - 1) { setSelectedIndex(selectedIndex + 1); return; } if (!cursor || loadingMore) { return; } setLoadingMore(true); try { const { items } = await loadPage(cursor); if (items.length > 0) { setSelectedIndex((prev) => (typeof prev === 'number' ? prev + 1 : prev)); } } finally { setLoadingMore(false); } }, [cursor, loadPage, loadingMore, photos.length, selectedIndex]); const handleLike = React.useCallback(async () => { if (!selected || !token) return; setLikes((prev) => ({ ...prev, [selected.id]: (prev[selected.id] ?? selected.likes) + 1 })); try { const count = await likePhoto(selected.id); setLikes((prev) => ({ ...prev, [selected.id]: count })); } catch (error) { console.error('Like failed', error); } }, [selected, 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 (!selected || !token) return; setShareSheet({ url: null, loading: true }); try { const payload = await createPhotoShareLink(token, selected.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 }); } }, [selected, 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((url?: string | null, id?: number | null) => { if (!url || !id) return; const link = document.createElement('a'); link.href = url; link.download = `photo-${id}.jpg`; link.rel = 'noreferrer'; document.body.appendChild(link); link.click(); document.body.removeChild(link); }, []); 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) { goPrev(); } else { void goNext(); } } } 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; }; return ( {t('galleryPage.title', 'Gallery')} {loading ? ( {t('galleryPage.loading', 'Loading…')} ) : error ? ( {error} ) : selected ? (
`translate3d(${nx}px, ${ny}px, 0) scale(${ns})`), width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', }} > {t('galleryPage.photo.alt',
{selectedIndex !== null ? `${selectedIndex + 1} / ${photos.length}` : ''} {t('galleryPage.lightbox.likes', { count: likes[selected.id] ?? selected.likes }, '{count} likes')}
) : ( {t('lightbox.errors.notFound', 'Photo not found')} )} { if (!open) { closeShareSheet(); } }} photoId={selected?.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" />
); }