Files
fotospiel-app/resources/js/guest-v2/screens/GalleryScreen.tsx
2026-02-03 22:16:02 +01:00

451 lines
17 KiB
TypeScript

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<GalleryTile[]>([]);
const [loading, setLoading] = React.useState(false);
const { data: delta } = usePollGalleryDelta(token ?? null, { locale });
const [filter, setFilter] = React.useState<GalleryFilter>('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<string, unknown>;
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<number>(raw ? JSON.parse(raw) : []);
} catch {
return new Set<number>();
}
}, [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<string, unknown>).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<string, unknown>;
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 (
<AppShell>
<YStack gap="$4">
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$3"
style={{
boxShadow: cardShadow,
}}
>
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" gap="$2">
<ImageIcon size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$4" fontWeight="$7">
{t('galleryPage.title', 'Gallery')}
</Text>
</XStack>
<Button
size="$3"
backgroundColor={mutedButton}
borderRadius="$pill"
borderWidth={1}
borderColor={mutedButtonBorder}
>
<Filter size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
<XStack gap="$2" flexWrap="wrap">
{(
[
{ 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) => (
<Button
key={chip.value}
size="$3"
backgroundColor={filter === chip.value ? '$primary' : mutedButton}
borderRadius="$pill"
borderWidth={1}
borderColor={filter === chip.value ? '$primary' : mutedButtonBorder}
onPress={() => setFilter(chip.value)}
>
<Text fontSize="$2" fontWeight="$6" color={filter === chip.value ? '#FFFFFF' : undefined}>
{chip.label}
</Text>
</Button>
))}
</XStack>
</YStack>
{isEmpty ? (
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$3"
alignItems="center"
style={{
boxShadow: cardShadow,
}}
>
<YStack
width={64}
height={64}
borderRadius={20}
backgroundColor="$primary"
alignItems="center"
justifyContent="center"
style={{ boxShadow: cardShadow }}
>
<Camera size={28} color="#FFFFFF" />
</YStack>
<Text fontSize="$4" fontWeight="$7" textAlign="center">
{t('galleryPage.emptyTitle', 'Noch keine Fotos')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7} textAlign="center">
{t('galleryPage.emptyDescription', 'Lade das erste Foto hoch und starte die Galerie.')}
</Text>
<Button
size="$3"
backgroundColor="$primary"
borderRadius="$pill"
onPress={() => navigate(uploadPath)}
>
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
{t('galleryPage.emptyCta', 'Foto hochladen')}
</Text>
</Button>
</YStack>
) : isSingle ? (
<YStack gap="$3">
<Button unstyled onPress={() => openLightbox(displayPhotos[0].id)}>
<PhotoFrameTile height={360} borderRadius="$card">
<YStack flex={1} width="100%" height="100%" alignItems="center" justifyContent="center">
<img
src={displayPhotos[0].imageUrl}
alt={t('galleryPage.photo.alt', { id: displayPhotos[0].id }, 'Photo {id}')}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</YStack>
</PhotoFrameTile>
</Button>
<Button unstyled onPress={() => navigate(uploadPath)}>
<PhotoFrameTile height={160} borderRadius="$card">
<YStack flex={1} alignItems="center" justifyContent="center" gap="$2" padding="$3">
<YStack
width={48}
height={48}
borderRadius={16}
backgroundColor="$primary"
alignItems="center"
justifyContent="center"
style={{ boxShadow: cardShadow }}
>
<Camera size={20} color="#FFFFFF" />
</YStack>
<Text fontSize="$3" fontWeight="$7" textAlign="center">
{t('galleryPage.promptTitle', 'Füge dein nächstes Foto hinzu')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7} textAlign="center">
{t('galleryPage.promptDescription', 'Teile den Moment mit allen Gästen.')}
</Text>
</YStack>
</PhotoFrameTile>
</Button>
</YStack>
) : (
<XStack gap="$3">
<YStack flex={1} gap="$3">
{(loading ? Array.from({ length: 5 }, (_, index) => index) : leftColumn).map((tile, index) => {
if (typeof tile === 'number') {
return <PhotoFrameTile key={`left-${tile}`} height={140 + (index % 3) * 24} shimmer shimmerDelayMs={200 + index * 120} />;
}
return (
<Button
key={tile.id}
unstyled
onPress={() => openLightbox(tile.id)}
>
<PhotoFrameTile height={140 + (index % 3) * 24}>
<YStack flex={1} width="100%" height="100%" alignItems="center" justifyContent="center">
<img
src={tile.imageUrl}
alt={t('galleryPage.photo.alt', { id: tile.id }, 'Photo {id}')}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</YStack>
</PhotoFrameTile>
</Button>
);
})}
</YStack>
<YStack flex={1} gap="$3">
{(loading ? Array.from({ length: 5 }, (_, index) => index) : rightColumn).map((tile, index) => {
if (typeof tile === 'number') {
return <PhotoFrameTile key={`right-${tile}`} height={120 + (index % 3) * 28} shimmer shimmerDelayMs={260 + index * 140} />;
}
return (
<Button
key={tile.id}
unstyled
onPress={() => openLightbox(tile.id)}
>
<PhotoFrameTile height={120 + (index % 3) * 28}>
<YStack flex={1} width="100%" height="100%" alignItems="center" justifyContent="center">
<img
src={tile.imageUrl}
alt={t('galleryPage.photo.alt', { id: tile.id }, 'Photo {id}')}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</YStack>
</PhotoFrameTile>
</Button>
);
})}
{!loading && rightColumn.length === 0 ? (
<Button unstyled onPress={() => navigate(uploadPath)}>
<PhotoFrameTile height={180}>
<YStack flex={1} alignItems="center" justifyContent="center" gap="$2" padding="$3">
<YStack
width={46}
height={46}
borderRadius={16}
backgroundColor="$primary"
alignItems="center"
justifyContent="center"
style={{ boxShadow: cardShadow }}
>
<Camera size={20} color="#FFFFFF" />
</YStack>
<Text fontSize="$3" fontWeight="$7" textAlign="center">
{t('galleryPage.promptTitle', 'Füge dein nächstes Foto hinzu')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7} textAlign="center">
{t('galleryPage.promptDescription', 'Teile den Moment mit allen Gästen.')}
</Text>
</YStack>
</PhotoFrameTile>
</Button>
) : null}
</YStack>
</XStack>
)}
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$1"
style={{
boxShadow: cardShadow,
}}
>
<Text fontSize="$3" fontWeight="$7">
{t('galleryPage.feed.title', 'Live feed')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{newUploads > 0
? t('galleryPage.feed.newUploads', { count: newUploads }, '{count} new uploads just landed.')
: t('galleryPage.feed.description', 'Updated every few seconds.')}
</Text>
</YStack>
</YStack>
</AppShell>
);
}