Files
fotospiel-app/resources/js/guest-v2/screens/PublicGalleryScreen.tsx
Codex Agent 0a08f2704f
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
refactor(guest): retire legacy guest app and move shared modules
2026-02-06 08:42:53 +01:00

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>
);
}