upgrade to tamagui v2 and guest pwa overhaul
This commit is contained in:
306
resources/js/guest-v2/screens/PublicGalleryScreen.tsx
Normal file
306
resources/js/guest-v2/screens/PublicGalleryScreen.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
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 { Image as ImageIcon, Download, Share2 } from 'lucide-react';
|
||||
import { Sheet } from '@tamagui/sheet';
|
||||
import StandaloneShell from '../components/StandaloneShell';
|
||||
import SurfaceCard from '../components/SurfaceCard';
|
||||
import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type GalleryPhotoResource } from '@/guest/services/galleryApi';
|
||||
import { createPhotoShareLink } from '@/guest/services/photosApi';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { EventBrandingProvider } from '@/guest/context/EventBrandingContext';
|
||||
import { mapEventBranding } from '../lib/eventBranding';
|
||||
import { BrandingTheme } from '../lib/brandingTheme';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
|
||||
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 { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
||||
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;
|
||||
const raw = state.meta.branding ?? null;
|
||||
return mapEventBranding({
|
||||
primary_color: raw.primary_color,
|
||||
secondary_color: raw.secondary_color,
|
||||
background_color: raw.background_color,
|
||||
surface_color: raw.surface_color ?? raw.background_color,
|
||||
palette: raw.palette ?? undefined,
|
||||
mode: raw.mode ?? 'auto',
|
||||
} as any);
|
||||
}, [state.meta]);
|
||||
|
||||
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="$2">
|
||||
<ImageIcon size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$4" fontWeight="$7">
|
||||
{t('galleryPublic.title')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$2" color={mutedText} marginTop="$2">
|
||||
{state.meta?.event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
|
||||
</Text>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user