451 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|