301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
import React from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import { YStack, XStack } from '@tamagui/stacks';
|
|
import { SizableText as Text } from '@tamagui/text';
|
|
import { Button } from '@tamagui/button';
|
|
import { Download, Share2 } from 'lucide-react';
|
|
import { Sheet } from '@tamagui/sheet';
|
|
import StandaloneShell from '../components/StandaloneShell';
|
|
import SurfaceCard from '../components/SurfaceCard';
|
|
import EventLogo from '../components/EventLogo';
|
|
import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type GalleryPhotoResource } from '@/shared/guest/services/galleryApi';
|
|
import { createPhotoShareLink } from '@/shared/guest/services/photosApi';
|
|
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
|
|
import { EventBrandingProvider } from '@/shared/guest/context/EventBrandingContext';
|
|
import { mapEventBranding } from '../lib/eventBranding';
|
|
import { BrandingTheme } from '../lib/brandingTheme';
|
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
|
|
|
type GalleryState = {
|
|
meta: GalleryMetaResponse | null;
|
|
photos: GalleryPhotoResource[];
|
|
cursor: string | null;
|
|
loading: boolean;
|
|
loadingMore: boolean;
|
|
error: string | null;
|
|
expired: boolean;
|
|
};
|
|
|
|
const INITIAL_STATE: GalleryState = {
|
|
meta: null,
|
|
photos: [],
|
|
cursor: null,
|
|
loading: true,
|
|
loadingMore: false,
|
|
error: null,
|
|
expired: false,
|
|
};
|
|
|
|
const PAGE_SIZE = 30;
|
|
|
|
export default function PublicGalleryScreen() {
|
|
const { token } = useParams<{ token: string }>();
|
|
const { t, locale } = useTranslation();
|
|
const [state, setState] = React.useState<GalleryState>(INITIAL_STATE);
|
|
const [selected, setSelected] = React.useState<GalleryPhotoResource | null>(null);
|
|
const [shareLoading, setShareLoading] = React.useState(false);
|
|
const sentinelRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
const branding = React.useMemo(() => {
|
|
if (!state.meta) return null;
|
|
return mapEventBranding(state.meta.branding ?? null);
|
|
}, [state.meta]);
|
|
const { isDark } = useGuestThemeVariant(branding);
|
|
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
|
|
|
const loadInitial = React.useCallback(async () => {
|
|
if (!token) return;
|
|
setState((prev) => ({ ...prev, loading: true, error: null, expired: false, photos: [], cursor: null }));
|
|
try {
|
|
const meta = await fetchGalleryMeta(token, locale);
|
|
const photoResponse = await fetchGalleryPhotos(token, null, PAGE_SIZE);
|
|
setState((prev) => ({
|
|
...prev,
|
|
meta,
|
|
photos: photoResponse.data,
|
|
cursor: photoResponse.next_cursor,
|
|
loading: false,
|
|
}));
|
|
} catch (error) {
|
|
const err = error as Error & { code?: string | number };
|
|
if (err.code === 'gallery_expired' || err.code === 410) {
|
|
setState((prev) => ({ ...prev, loading: false, expired: true }));
|
|
} else {
|
|
setState((prev) => ({ ...prev, loading: false, error: err.message || t('galleryPublic.loadError') }));
|
|
}
|
|
}
|
|
}, [locale, t, token]);
|
|
|
|
React.useEffect(() => {
|
|
loadInitial();
|
|
}, [loadInitial]);
|
|
|
|
const loadMore = React.useCallback(async () => {
|
|
if (!token || !state.cursor || state.loadingMore) return;
|
|
setState((prev) => ({ ...prev, loadingMore: true }));
|
|
try {
|
|
const response = await fetchGalleryPhotos(token, state.cursor, PAGE_SIZE);
|
|
setState((prev) => ({
|
|
...prev,
|
|
photos: [...prev.photos, ...response.data],
|
|
cursor: response.next_cursor,
|
|
loadingMore: false,
|
|
}));
|
|
} catch (error) {
|
|
const err = error as Error;
|
|
setState((prev) => ({ ...prev, loadingMore: false, error: err.message || t('galleryPublic.loadError') }));
|
|
}
|
|
}, [state.cursor, state.loadingMore, t, token]);
|
|
|
|
React.useEffect(() => {
|
|
if (!state.cursor || !sentinelRef.current) return;
|
|
const observer = new IntersectionObserver((entries) => {
|
|
if (entries[0]?.isIntersecting) loadMore();
|
|
}, { rootMargin: '400px', threshold: 0 });
|
|
observer.observe(sentinelRef.current);
|
|
return () => observer.disconnect();
|
|
}, [state.cursor, loadMore]);
|
|
|
|
const content = (
|
|
<StandaloneShell>
|
|
<SurfaceCard glow>
|
|
<XStack alignItems="center" gap="$3">
|
|
<EventLogo name={state.meta?.event?.name ?? t('galleryPage.hero.eventFallback', 'Event')} size="s" />
|
|
<YStack gap="$1">
|
|
<Text fontSize="$4" fontWeight="$7">
|
|
{t('galleryPublic.title')}
|
|
</Text>
|
|
<Text fontSize="$2" color={mutedText}>
|
|
{state.meta?.event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
|
|
</Text>
|
|
</YStack>
|
|
</XStack>
|
|
</SurfaceCard>
|
|
|
|
{state.loading ? (
|
|
<SurfaceCard>
|
|
<Text fontSize="$3" color={mutedText}>
|
|
{t('galleryPublic.loading')}
|
|
</Text>
|
|
</SurfaceCard>
|
|
) : state.expired ? (
|
|
<SurfaceCard>
|
|
<Text fontSize="$4" fontWeight="$7">
|
|
{t('galleryPublic.expiredTitle')}
|
|
</Text>
|
|
<Text fontSize="$2" color={mutedText} marginTop="$2">
|
|
{t('galleryPublic.expiredDescription')}
|
|
</Text>
|
|
</SurfaceCard>
|
|
) : state.error ? (
|
|
<SurfaceCard>
|
|
<Text fontSize="$3" fontWeight="$7">
|
|
{t('galleryPublic.loadError')}
|
|
</Text>
|
|
<Text fontSize="$2" color={mutedText} marginTop="$2">
|
|
{state.error}
|
|
</Text>
|
|
</SurfaceCard>
|
|
) : state.photos.length === 0 ? (
|
|
<SurfaceCard>
|
|
<Text fontSize="$4" fontWeight="$7">
|
|
{t('galleryPublic.emptyTitle')}
|
|
</Text>
|
|
<Text fontSize="$2" color={mutedText} marginTop="$2">
|
|
{t('galleryPublic.emptyDescription')}
|
|
</Text>
|
|
</SurfaceCard>
|
|
) : (
|
|
<YStack gap="$3">
|
|
<XStack gap="$3">
|
|
<YStack flex={1} gap="$3">
|
|
{state.photos.filter((_, index) => index % 2 === 0).map((photo, index) => (
|
|
<Button key={photo.id} unstyled onPress={() => setSelected(photo)}>
|
|
<SurfaceCard padding={0} overflow="hidden" height={150 + (index % 3) * 32}>
|
|
<YStack
|
|
flex={1}
|
|
style={{
|
|
backgroundImage: `url(${photo.thumbnail_url ?? photo.full_url})`,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
}}
|
|
/>
|
|
</SurfaceCard>
|
|
</Button>
|
|
))}
|
|
</YStack>
|
|
<YStack flex={1} gap="$3">
|
|
{state.photos.filter((_, index) => index % 2 === 1).map((photo, index) => (
|
|
<Button key={photo.id} unstyled onPress={() => setSelected(photo)}>
|
|
<SurfaceCard padding={0} overflow="hidden" height={140 + (index % 3) * 28}>
|
|
<YStack
|
|
flex={1}
|
|
style={{
|
|
backgroundImage: `url(${photo.thumbnail_url ?? photo.full_url})`,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
}}
|
|
/>
|
|
</SurfaceCard>
|
|
</Button>
|
|
))}
|
|
</YStack>
|
|
</XStack>
|
|
{state.loadingMore ? (
|
|
<SurfaceCard>
|
|
<Text fontSize="$2" color={mutedText}>
|
|
{t('galleryPublic.loadingMore')}
|
|
</Text>
|
|
</SurfaceCard>
|
|
) : null}
|
|
<div ref={sentinelRef} />
|
|
</YStack>
|
|
)}
|
|
|
|
<Sheet
|
|
open={Boolean(selected)}
|
|
onOpenChange={(next) => {
|
|
if (!next) setSelected(null);
|
|
}}
|
|
snapPoints={[92]}
|
|
position={selected ? 0 : -1}
|
|
modal
|
|
>
|
|
<Sheet.Overlay {...({ backgroundColor: isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(15, 23, 42, 0.3)' } as any)} />
|
|
<Sheet.Frame padding="$4" backgroundColor="$surface" borderTopLeftRadius="$6" borderTopRightRadius="$6">
|
|
<Sheet.Handle height={5} width={52} backgroundColor="#CBD5E1" borderRadius={999} marginBottom="$3" />
|
|
{selected ? (
|
|
<YStack gap="$4">
|
|
<YStack
|
|
height={360}
|
|
borderRadius="$card"
|
|
backgroundColor="$muted"
|
|
overflow="hidden"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
>
|
|
<img
|
|
src={selected.full_url ?? selected.thumbnail_url ?? ''}
|
|
alt={selected.guest_name ?? t('galleryPublic.lightboxGuestFallback', 'Guest')}
|
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
|
/>
|
|
</YStack>
|
|
<XStack alignItems="center" justifyContent="space-between" flexWrap="wrap" gap="$2">
|
|
<Text fontSize="$3" fontWeight="$7">
|
|
{selected.likes_count ? `${selected.likes_count} ❤` : ''}
|
|
</Text>
|
|
<XStack gap="$2">
|
|
{(state.meta?.event?.guest_downloads_enabled ?? true) && selected.download_url ? (
|
|
<Button size="$3" borderRadius="$pill" backgroundColor="$primary" asChild>
|
|
<a href={selected.download_url} target="_blank" rel="noopener noreferrer">
|
|
<Download size={16} color="white" />
|
|
<Text fontSize="$2" fontWeight="$7" color="white">
|
|
{t('galleryPublic.download')}
|
|
</Text>
|
|
</a>
|
|
</Button>
|
|
) : null}
|
|
{(state.meta?.event?.guest_sharing_enabled ?? true) ? (
|
|
<Button
|
|
size="$3"
|
|
borderRadius="$pill"
|
|
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
|
|
borderWidth={1}
|
|
borderColor={isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
|
|
onPress={async () => {
|
|
if (!token || !selected) return;
|
|
setShareLoading(true);
|
|
try {
|
|
const payload = await createPhotoShareLink(token, selected.id);
|
|
const shareData: ShareData = {
|
|
title: selected.guest_name ?? t('share.title', 'Geteiltes Foto'),
|
|
text: t('share.shareText', 'Check out this moment on Fotospiel.'),
|
|
url: payload.url,
|
|
};
|
|
if (navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
|
|
await navigator.share(shareData).catch(() => undefined);
|
|
} else if (payload.url) {
|
|
await navigator.clipboard.writeText(payload.url);
|
|
}
|
|
} finally {
|
|
setShareLoading(false);
|
|
}
|
|
}}
|
|
disabled={shareLoading}
|
|
>
|
|
<Share2 size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
<Text fontSize="$2" fontWeight="$6">
|
|
{shareLoading ? t('common.actions.loading', 'Loading...') : t('share.shareCta', 'Teilen')}
|
|
</Text>
|
|
</Button>
|
|
) : null}
|
|
</XStack>
|
|
</XStack>
|
|
</YStack>
|
|
) : null}
|
|
</Sheet.Frame>
|
|
</Sheet>
|
|
</StandaloneShell>
|
|
);
|
|
|
|
if (!branding) {
|
|
return content;
|
|
}
|
|
|
|
return (
|
|
<EventBrandingProvider branding={branding}>
|
|
<BrandingTheme>{content}</BrandingTheme>
|
|
</EventBrandingProvider>
|
|
);
|
|
}
|