331 lines
11 KiB
TypeScript
331 lines
11 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 { AlertCircle, Download, Maximize2, X } from 'lucide-react';
|
|
import StandaloneShell from '../components/StandaloneShell';
|
|
import SurfaceCard from '../components/SurfaceCard';
|
|
import EventLogo from '../components/EventLogo';
|
|
import { fetchPhotoShare } from '@/shared/guest/services/photosApi';
|
|
import type { EventBrandingPayload } from '@/shared/guest/services/eventApi';
|
|
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';
|
|
import { getBentoSurfaceTokens } from '../lib/bento';
|
|
|
|
interface ShareResponse {
|
|
slug: string;
|
|
expires_at?: string;
|
|
photo: {
|
|
id: number;
|
|
title?: string;
|
|
likes_count?: number;
|
|
emotion?: { name?: string; emoji?: string | null } | null;
|
|
created_at?: string | null;
|
|
image_urls: { full: string; thumbnail: string };
|
|
};
|
|
event?: { id: number; name?: string | null } | null;
|
|
branding?: EventBrandingPayload | null;
|
|
}
|
|
|
|
export default function SharedPhotoScreen() {
|
|
const { slug } = useParams<{ slug: string }>();
|
|
const { t } = useTranslation();
|
|
const [state, setState] = React.useState<{ loading: boolean; error: string | null; data: ShareResponse | null }>({
|
|
loading: true,
|
|
error: null,
|
|
data: null,
|
|
});
|
|
const [fullScreenOpen, setFullScreenOpen] = React.useState(false);
|
|
const branding = React.useMemo(() => {
|
|
if (!state.data?.branding) {
|
|
return null;
|
|
}
|
|
return mapEventBranding(state.data.branding);
|
|
}, [state.data]);
|
|
const { isDark } = useGuestThemeVariant(branding);
|
|
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
|
const bento = getBentoSurfaceTokens(isDark);
|
|
|
|
React.useEffect(() => {
|
|
let active = true;
|
|
if (!slug) return;
|
|
setState({ loading: true, error: null, data: null });
|
|
|
|
fetchPhotoShare(slug)
|
|
.then((data) => {
|
|
if (active) setState({ loading: false, error: null, data });
|
|
})
|
|
.catch(() => {
|
|
if (active) {
|
|
setState({
|
|
loading: false,
|
|
error: t('share.expiredDescription', 'Dieser Link ist nicht mehr verfügbar.'),
|
|
data: null,
|
|
});
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [slug]);
|
|
|
|
if (state.loading) {
|
|
return (
|
|
<StandaloneShell>
|
|
<SurfaceCard>
|
|
<Text fontSize="$3" color={mutedText}>
|
|
{t('share.loading', 'Moment wird geladen …')}
|
|
</Text>
|
|
</SurfaceCard>
|
|
</StandaloneShell>
|
|
);
|
|
}
|
|
|
|
if (state.error || !state.data) {
|
|
return (
|
|
<StandaloneShell>
|
|
<SurfaceCard>
|
|
<XStack alignItems="center" gap="$2">
|
|
<AlertCircle size={18} color={isDark ? '#FCA5A5' : '#B91C1C'} />
|
|
<Text fontSize="$4" fontWeight="$7">
|
|
{t('share.expiredTitle', 'Link abgelaufen')}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$2" color={mutedText} marginTop="$2">
|
|
{state.error ?? t('share.expiredDescription', 'Dieses Foto ist nicht mehr verfügbar.')}
|
|
</Text>
|
|
</SurfaceCard>
|
|
</StandaloneShell>
|
|
);
|
|
}
|
|
|
|
const { data } = state;
|
|
const chips = buildChips(data, t);
|
|
|
|
const content = (
|
|
<StandaloneShell compact>
|
|
<YStack gap="$3" style={{ paddingBottom: 'calc(var(--space-6) + 50px)' }}>
|
|
<YStack
|
|
padding="$4"
|
|
borderRadius="$card"
|
|
borderWidth={1}
|
|
borderBottomWidth={2}
|
|
borderColor={bento.borderColor}
|
|
borderBottomColor={bento.borderBottomColor}
|
|
backgroundColor={bento.backgroundColor}
|
|
style={{ boxShadow: bento.shadow }}
|
|
>
|
|
<XStack alignItems="center" gap="$3" flexWrap="wrap">
|
|
<EventLogo name={data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')} size="s" />
|
|
<YStack gap="$1" flex={1} minWidth={180}>
|
|
<Text fontSize="$2" letterSpacing={2} textTransform="uppercase" color={mutedText}>
|
|
{t('share.title', 'Geteiltes Foto')}
|
|
</Text>
|
|
<Text fontSize="$6" fontWeight="$8">
|
|
{data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')}
|
|
</Text>
|
|
</YStack>
|
|
</XStack>
|
|
{data.photo.title ? (
|
|
<Text fontSize="$3" color={mutedText} marginTop="$2" numberOfLines={2}>
|
|
{data.photo.title}
|
|
</Text>
|
|
) : null}
|
|
</YStack>
|
|
|
|
<YStack
|
|
padding="$2"
|
|
borderRadius="$card"
|
|
borderWidth={1}
|
|
borderBottomWidth={2}
|
|
borderColor={bento.borderColor}
|
|
borderBottomColor={bento.borderBottomColor}
|
|
backgroundColor={bento.backgroundColor}
|
|
style={{ boxShadow: bento.shadow }}
|
|
>
|
|
<YStack
|
|
borderRadius="$card"
|
|
overflow="hidden"
|
|
position="relative"
|
|
style={{
|
|
height: 'min(45vh, 320px)',
|
|
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.4)' : 'rgba(15, 23, 42, 0.06)',
|
|
backgroundImage: `url(${data.photo.image_urls.full})`,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
}}
|
|
>
|
|
<Button
|
|
size="$3"
|
|
circular
|
|
position="absolute"
|
|
top="$2"
|
|
right="$2"
|
|
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(255, 255, 255, 0.85)'}
|
|
borderWidth={1}
|
|
borderColor={bento.borderColor}
|
|
onPress={() => setFullScreenOpen(true)}
|
|
aria-label={t('share.fullscreen', 'Fullscreen')}
|
|
>
|
|
<Maximize2 size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
</Button>
|
|
</YStack>
|
|
</YStack>
|
|
|
|
{chips.length > 0 ? (
|
|
<XStack gap="$2" flexWrap="wrap" justifyContent="center">
|
|
{chips.map((chip) => (
|
|
<SurfaceCard key={chip.id} padding="$2" borderRadius="$pill" minWidth={120}>
|
|
<XStack alignItems="center" gap="$2" flexWrap="wrap">
|
|
{chip.icon ? <Text fontSize="$3">{chip.icon}</Text> : null}
|
|
<Text fontSize="$2" color={mutedText}>
|
|
{chip.label}
|
|
</Text>
|
|
<Text fontSize="$2" fontWeight="$7" numberOfLines={2} style={{ maxWidth: 220 }}>
|
|
{chip.value}
|
|
</Text>
|
|
</XStack>
|
|
</SurfaceCard>
|
|
))}
|
|
</XStack>
|
|
) : null}
|
|
|
|
<YStack style={{ position: 'sticky', bottom: 12 }}>
|
|
<Button
|
|
size="$4"
|
|
borderRadius="$pill"
|
|
backgroundColor="$primary"
|
|
onPress={() => window.open(data.photo.image_urls.full, '_blank')}
|
|
justifyContent="center"
|
|
>
|
|
<XStack alignItems="center" justifyContent="center" gap="$2" width="100%">
|
|
<Download size={18} color="white" />
|
|
<Text fontSize="$3" fontWeight="$7" color="white">
|
|
{t('galleryPublic.download', 'Download')}
|
|
</Text>
|
|
</XStack>
|
|
</Button>
|
|
</YStack>
|
|
</YStack>
|
|
{fullScreenOpen ? (
|
|
<YStack
|
|
position="fixed"
|
|
top={0}
|
|
left={0}
|
|
right={0}
|
|
bottom={0}
|
|
zIndex={3000}
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
style={{
|
|
backgroundColor: isDark ? 'rgba(2, 6, 23, 0.8)' : 'rgba(15, 23, 42, 0.45)',
|
|
backdropFilter: 'blur(14px)',
|
|
}}
|
|
>
|
|
<Button
|
|
unstyled
|
|
onPress={() => setFullScreenOpen(false)}
|
|
style={{ position: 'absolute', inset: 0 }}
|
|
aria-label={t('common.actions.close', 'Close')}
|
|
/>
|
|
<YStack
|
|
width="92vw"
|
|
height="82vh"
|
|
maxWidth={920}
|
|
maxHeight={720}
|
|
borderRadius="$bentoLg"
|
|
overflow="hidden"
|
|
borderWidth={1}
|
|
borderColor={bento.borderColor}
|
|
style={{ position: 'relative', boxShadow: bento.shadow }}
|
|
>
|
|
<YStack
|
|
position="absolute"
|
|
top={0}
|
|
left={0}
|
|
right={0}
|
|
bottom={0}
|
|
style={{
|
|
backgroundImage: `url(${data.photo.image_urls.full})`,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
filter: 'blur(26px)',
|
|
transform: 'scale(1.08)',
|
|
opacity: 0.6,
|
|
}}
|
|
/>
|
|
<YStack flex={1} alignItems="center" justifyContent="center" padding="$4" style={{ zIndex: 1 }}>
|
|
<img
|
|
src={data.photo.image_urls.full}
|
|
alt={t('galleryPage.photo.alt', { id: data.photo.id, suffix: '' }, `Foto ${data.photo.id}`)}
|
|
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
|
/>
|
|
</YStack>
|
|
<Button
|
|
size="$3"
|
|
circular
|
|
position="absolute"
|
|
top="$3"
|
|
right="$3"
|
|
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(255, 255, 255, 0.85)'}
|
|
borderWidth={1}
|
|
borderColor={bento.borderColor}
|
|
onPress={() => setFullScreenOpen(false)}
|
|
aria-label={t('common.actions.close', 'Close')}
|
|
style={{ zIndex: 2 }}
|
|
>
|
|
<X size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
</Button>
|
|
</YStack>
|
|
</YStack>
|
|
) : null}
|
|
</StandaloneShell>
|
|
);
|
|
|
|
if (!branding) {
|
|
return content;
|
|
}
|
|
|
|
return (
|
|
<EventBrandingProvider branding={branding}>
|
|
<BrandingTheme>
|
|
{content}
|
|
</BrandingTheme>
|
|
</EventBrandingProvider>
|
|
);
|
|
}
|
|
|
|
function buildChips(
|
|
data: ShareResponse,
|
|
t: (key: string, fallback?: string) => string
|
|
): { id: string; label: string; value: string; icon?: string }[] {
|
|
const list: { id: string; label: string; value: string; icon?: string }[] = [];
|
|
if (data.photo.emotion?.name) {
|
|
list.push({
|
|
id: 'emotion',
|
|
label: t('share.chips.emotion', 'Emotion'),
|
|
value: data.photo.emotion.name,
|
|
icon: data.photo.emotion.emoji ?? '★',
|
|
});
|
|
}
|
|
if (data.photo.title) {
|
|
list.push({ id: 'task', label: t('share.chips.task', 'Aufgabe'), value: data.photo.title });
|
|
}
|
|
if (data.photo.created_at) {
|
|
const date = formatDate(data.photo.created_at);
|
|
list.push({ id: 'date', label: t('share.chips.date', 'Aufgenommen'), value: date });
|
|
}
|
|
return list;
|
|
}
|
|
|
|
function formatDate(value: string): string {
|
|
const parsed = new Date(value);
|
|
if (Number.isNaN(parsed.getTime())) return '';
|
|
return parsed.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
|
}
|