import React from 'react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; import { Camera, Image as ImageIcon, Filter } from 'lucide-react'; import AppShell from '../components/AppShell'; import PhotoFrameTile from '../components/PhotoFrameTile'; import { useEventData } from '../context/EventDataContext'; import { fetchGallery } from '../services/photosApi'; import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta'; import { useGuestThemeVariant } from '../lib/guestTheme'; import { useTranslation } from '@/guest/i18n/useTranslation'; import { useLocale } from '@/guest/i18n/LocaleContext'; import { useNavigate } from 'react-router-dom'; import { buildEventPath } from '../lib/routes'; type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth'; type GalleryTile = { id: number; imageUrl: string; likes: number; createdAt?: string | null; ingestSource?: string | null; sessionId?: 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, '/'); } export default function GalleryScreen() { const { token } = useEventData(); const { t } = useTranslation(); const { locale } = useLocale(); const navigate = useNavigate(); const { isDark } = useGuestThemeVariant(); const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'; const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px 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 [photos, setPhotos] = React.useState([]); const [loading, setLoading] = React.useState(false); const { data: delta } = usePollGalleryDelta(token ?? null, { locale }); const [filter, setFilter] = React.useState('latest'); const uploadPath = React.useMemo(() => buildEventPath(token ?? null, '/upload'), [token]); React.useEffect(() => { if (!token) { setPhotos([]); return; } let active = true; setLoading(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 imageUrl = normalizeImageUrl( (record.thumbnail_url as string | null | undefined) ?? (record.thumbnail_path as string | null | undefined) ?? (record.file_path as string | null | undefined) ?? (record.full_url as string | null | undefined) ?? (record.url as string | null | undefined) ?? (record.image_url as string | null | undefined) ); return { id, imageUrl, 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, }; }) .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); } }); return () => { active = false; }; }, [token, locale]); const myPhotoIds = React.useMemo(() => { try { const raw = localStorage.getItem('my-photo-ids'); return new Set(raw ? JSON.parse(raw) : []); } catch { return new Set(); } }, [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) => 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; React.useEffect(() => { if (filter === 'photobooth' && !photos.some((photo) => photo.ingestSource === 'photobooth')) { setFilter('latest'); } }, [filter, photos]); 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; navigate(buildEventPath(token, `/photo/${photoId}`)); }, [navigate, token] ); 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 imageUrl = normalizeImageUrl( (record.thumbnail_url as string | null | undefined) ?? (record.thumbnail_path as string | null | undefined) ?? (record.file_path as string | null | undefined) ?? (record.full_url as string | null | undefined) ?? (record.url as string | null | undefined) ?? (record.image_url as string | null | undefined) ); if (!id || !imageUrl || existing.has(id)) { return null; } return { id, imageUrl, 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, } satisfies GalleryTile; }) .filter(Boolean) as GalleryTile[]; if (mapped.length === 0) { return prev; } return [...mapped, ...prev]; }); }, [delta.photos]); return ( {t('galleryPage.title', 'Gallery')} {( [ { 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 ; } return ( ); })} {(loading ? Array.from({ length: 5 }, (_, index) => index) : rightColumn).map((tile, index) => { if (typeof tile === 'number') { return ; } return ( ); })} {!loading && rightColumn.length === 0 ? ( ) : null} )} {t('galleryPage.feed.title', 'Live feed')} {newUploads > 0 ? t('galleryPage.feed.newUploads', { count: newUploads }, '{count} new uploads just landed.') : t('galleryPage.feed.description', 'Updated every few seconds.')} ); }