Update guest PWA v2 UI and likes
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-05 15:09:19 +01:00
parent 6eafec2128
commit fa630e335d
22 changed files with 1288 additions and 200 deletions

View File

@@ -30,7 +30,7 @@ vi.mock('@tamagui/sheet', () => {
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useSearchParams: () => [new URLSearchParams()],
useSearchParams: () => [new URLSearchParams(), vi.fn()],
}));
vi.mock('lucide-react', () => ({
@@ -48,9 +48,14 @@ vi.mock('lucide-react', () => ({
Trophy: () => <span>trophy</span>,
Play: () => <span>play</span>,
Share2: () => <span>share</span>,
MessageSquare: () => <span>message</span>,
Copy: () => <span>copy</span>,
ChevronLeft: () => <span>chevron-left</span>,
ChevronRight: () => <span>chevron-right</span>,
QrCode: () => <span>qr</span>,
Link: () => <span>link</span>,
Users: () => <span>users</span>,
Heart: () => <span>heart</span>,
}));
vi.mock('../components/AppShell', () => ({
@@ -73,6 +78,10 @@ vi.mock('@/guest/services/pendingUploadsApi', () => ({
vi.mock('../services/photosApi', () => ({
fetchGallery: vi.fn().mockResolvedValue({ data: [], next_cursor: null, latest_photo_at: null, notModified: false }),
fetchPhoto: vi.fn().mockResolvedValue(null),
likePhoto: vi.fn().mockResolvedValue(0),
unlikePhoto: vi.fn().mockResolvedValue(0),
createPhotoShareLink: vi.fn().mockResolvedValue({ url: null }),
}));
vi.mock('../hooks/usePollGalleryDelta', () => ({
@@ -136,7 +145,7 @@ describe('Guest v2 screens copy', () => {
</EventDataProvider>
);
expect(screen.getByText('Gallery')).toBeInTheDocument();
expect(screen.getByText('Neues Foto hochladen')).toBeInTheDocument();
});
it('renders upload preview prompt', () => {

View File

@@ -1,8 +1,10 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import { XStack, YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { X } from 'lucide-react';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { getBentoSurfaceTokens } from '../lib/bento';
export type CompassAction = {
key: string;
@@ -40,6 +42,10 @@ export default function CompassHub({
}: CompassHubProps) {
const close = () => onOpenChange(false);
const { isDark } = useGuestThemeVariant();
const bentoSurface = getBentoSurfaceTokens(isDark);
const tileShadow = isDark
? '0 10px 0 rgba(2, 6, 23, 0.55), 0 20px 24px rgba(2, 6, 23, 0.45)'
: '0 10px 0 rgba(15, 23, 42, 0.18), 0 18px 22px rgba(15, 23, 42, 0.16)';
const [visible, setVisible] = React.useState(open);
const [closing, setClosing] = React.useState(false);
@@ -86,10 +92,11 @@ export default function CompassHub({
justifyContent="center"
pointerEvents="box-none"
>
<YStack alignItems="center" justifyContent="center" gap="$3" pointerEvents="auto">
<Text fontSize="$5" fontFamily="$display" fontWeight="$8" color="$color">
{title}
</Text>
<YStack
alignItems="center"
justifyContent="center"
pointerEvents="auto"
>
<YStack
key={closing ? 'compass-out' : 'compass-in'}
width={280}
@@ -97,6 +104,27 @@ export default function CompassHub({
position="relative"
className={closing ? 'guest-compass-flyout' : 'guest-compass-flyin'}
>
<Button
size="$3"
circular
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
onPress={close}
aria-label="Close compass"
style={{
position: 'absolute',
right: -18,
top: -18,
boxShadow: tileShadow,
transform: 'rotate(-6deg)',
zIndex: 2,
}}
>
<X size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
{quadrants.map((action, index) => (
<Button
key={action.key}
@@ -107,11 +135,14 @@ export default function CompassHub({
width={120}
height={120}
borderRadius={24}
backgroundColor="$surface"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderColor="$borderColor"
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
position="absolute"
{...quadrantPositions[index]}
style={{ boxShadow: tileShadow }}
>
<YStack alignItems="center" gap="$2">
{action.icon}
@@ -131,10 +162,17 @@ export default function CompassHub({
height={90}
borderRadius={45}
backgroundColor="$primary"
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
position="absolute"
top="50%"
left="50%"
style={{ transform: 'translate(-45px, -45px)' }}
style={{
transform: 'translate(-45px, -45px)',
boxShadow: tileShadow,
}}
>
<YStack alignItems="center" gap="$1">
{centerAction.icon}
@@ -144,9 +182,6 @@ export default function CompassHub({
</YStack>
</Button>
</YStack>
<Text fontSize="$2" color="$color" opacity={0.6}>
Tap outside to close
</Text>
</YStack>
</YStack>
</YStack>

View File

@@ -2,17 +2,21 @@ 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 { Camera, ChevronLeft, ChevronRight, Heart, Share2, Sparkles, X } from 'lucide-react';
import AppShell from '../components/AppShell';
import PhotoFrameTile from '../components/PhotoFrameTile';
import ShareSheet from '../components/ShareSheet';
import { useEventData } from '../context/EventDataContext';
import { fetchGallery } from '../services/photosApi';
import { createPhotoShareLink, fetchGallery, fetchPhoto, likePhoto, unlikePhoto } 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 { useNavigate, useSearchParams } from 'react-router-dom';
import { buildEventPath } from '../lib/routes';
import { getBentoSurfaceTokens } from '../lib/bento';
import { usePollStats } from '../hooks/usePollStats';
import { pushGuestToast } from '../lib/toast';
type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
@@ -25,6 +29,12 @@ type GalleryTile = {
sessionId?: string | null;
};
type LightboxPhoto = {
id: number;
imageUrl: string;
likes: number;
};
function normalizeImageUrl(src?: string | null) {
if (!src) {
return '';
@@ -43,20 +53,35 @@ function normalizeImageUrl(src?: string | null) {
}
export default function GalleryScreen() {
const { token } = useEventData();
const { token, event } = useEventData();
const { t } = useTranslation();
const { locale } = useLocale();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
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 bentoSurface = getBentoSurfaceTokens(isDark);
const cardShadow = bentoSurface.shadow;
const hardShadow = isDark
? '0 18px 0 rgba(2, 6, 23, 0.55), 0 32px 40px rgba(2, 6, 23, 0.55)'
: '0 18px 0 rgba(15, 23, 42, 0.22), 0 30px 36px rgba(15, 23, 42, 0.2)';
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 { stats } = usePollStats(token ?? null, 12000);
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
const uploadPath = React.useMemo(() => buildEventPath(token ?? null, '/upload'), [token]);
const numberFormatter = React.useMemo(() => new Intl.NumberFormat(locale), [locale]);
const [lightboxPhoto, setLightboxPhoto] = React.useState<LightboxPhoto | null>(null);
const [lightboxLoading, setLightboxLoading] = React.useState(false);
const [likesById, setLikesById] = React.useState<Record<number, number>>({});
const [shareSheet, setShareSheet] = React.useState<{ url: string | null; loading: boolean }>({
url: null,
loading: false,
});
const [likedIds, setLikedIds] = React.useState<Set<number>>(new Set());
const touchStartX = React.useRef<number | null>(null);
React.useEffect(() => {
if (!token) {
@@ -142,6 +167,15 @@ export default function GalleryScreen() {
const rightColumn = displayPhotos.filter((_, index) => index % 2 === 1);
const isEmpty = !loading && displayPhotos.length === 0;
const isSingle = !loading && displayPhotos.length === 1;
const selectedPhotoId = Number(searchParams.get('photo') ?? 0);
const lightboxIndex = React.useMemo(() => {
if (!selectedPhotoId) {
return -1;
}
return displayPhotos.findIndex((item) => item.id === selectedPhotoId);
}, [displayPhotos, selectedPhotoId]);
const lightboxSelected = lightboxIndex >= 0 ? displayPhotos[lightboxIndex] : null;
const lightboxOpen = Boolean(selectedPhotoId);
React.useEffect(() => {
if (filter === 'photobooth' && !photos.some((photo) => photo.ingestSource === 'photobooth')) {
@@ -164,10 +198,17 @@ export default function GalleryScreen() {
const openLightbox = React.useCallback(
(photoId: number) => {
if (!token) return;
navigate(buildEventPath(token, `/photo/${photoId}`));
const next = new URLSearchParams(searchParams);
next.set('photo', String(photoId));
setSearchParams(next, { replace: false });
},
[navigate, token]
[searchParams, setSearchParams, token]
);
const closeLightbox = React.useCallback(() => {
const next = new URLSearchParams(searchParams);
next.delete('photo');
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
React.useEffect(() => {
if (delta.photos.length === 0) {
@@ -208,73 +249,340 @@ export default function GalleryScreen() {
});
}, [delta.photos]);
const heroStatsLine = t(
'galleryPage.hero.stats',
{
photoCount: numberFormatter.format(photos.length),
likeCount: numberFormatter.format(stats.likesCount ?? 0),
guestCount: numberFormatter.format(stats.onlineGuests || stats.guestCount || 0),
},
`${numberFormatter.format(photos.length)} Fotos · ${numberFormatter.format(stats.likesCount ?? 0)} ❤️ · ${numberFormatter.format(stats.onlineGuests || stats.guestCount || 0)} Gäste online`
);
React.useEffect(() => {
setLikesById((prev) => {
const next = { ...prev };
for (const photo of photos) {
if (next[photo.id] === undefined) {
next[photo.id] = photo.likes;
}
}
return next;
});
}, [photos]);
React.useEffect(() => {
if (!lightboxOpen) {
setLightboxPhoto(null);
setLightboxLoading(false);
return;
}
const seed = lightboxSelected
? { id: lightboxSelected.id, imageUrl: lightboxSelected.imageUrl, likes: lightboxSelected.likes }
: null;
if (seed) {
setLightboxPhoto(seed);
}
let active = true;
setLightboxLoading(true);
fetchPhoto(selectedPhotoId, locale)
.then((photo) => {
if (!active || !photo) return;
const mapped = mapFullPhoto(photo as Record<string, unknown>);
if (mapped) {
setLightboxPhoto(mapped);
setLikesById((prev) => ({ ...prev, [mapped.id]: mapped.likes }));
}
})
.catch((error) => {
console.error('Lightbox photo load failed', error);
})
.finally(() => {
if (active) {
setLightboxLoading(false);
}
});
return () => {
active = false;
};
}, [lightboxOpen, lightboxSelected, locale, selectedPhotoId]);
React.useEffect(() => {
if (!lightboxOpen) {
document.body.style.overflow = '';
return;
}
document.body.style.overflow = 'hidden';
const handleKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeLightbox();
}
};
window.addEventListener('keydown', handleKey);
return () => {
document.body.style.overflow = '';
window.removeEventListener('keydown', handleKey);
};
}, [lightboxOpen]);
const goPrev = React.useCallback(() => {
if (lightboxIndex <= 0) return;
const prevId = displayPhotos[lightboxIndex - 1]?.id;
if (prevId) {
openLightbox(prevId);
}
}, [displayPhotos, lightboxIndex, openLightbox]);
const goNext = React.useCallback(() => {
if (lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1) return;
const nextId = displayPhotos[lightboxIndex + 1]?.id;
if (nextId) {
openLightbox(nextId);
}
}, [displayPhotos, lightboxIndex, openLightbox]);
const handleLike = React.useCallback(async () => {
if (!lightboxPhoto) return;
const isLiked = likedIds.has(lightboxPhoto.id);
const current = likesById[lightboxPhoto.id] ?? lightboxPhoto.likes;
const nextCount = Math.max(0, current + (isLiked ? -1 : 1));
setLikedIds((prev) => {
const next = new Set(prev);
if (isLiked) {
next.delete(lightboxPhoto.id);
} else {
next.add(lightboxPhoto.id);
}
return next;
});
setLikesById((prev) => ({ ...prev, [lightboxPhoto.id]: nextCount }));
try {
const count = isLiked ? await unlikePhoto(lightboxPhoto.id) : await likePhoto(lightboxPhoto.id);
setLikesById((prev) => ({ ...prev, [lightboxPhoto.id]: count }));
} catch (error) {
console.error('Like failed', error);
setLikedIds((prev) => {
const next = new Set(prev);
if (isLiked) {
next.add(lightboxPhoto.id);
} else {
next.delete(lightboxPhoto.id);
}
return next;
});
setLikesById((prev) => ({ ...prev, [lightboxPhoto.id]: current }));
}
}, [lightboxPhoto, likedIds, likesById]);
const shareTitle = event?.name ?? t('share.title', 'Shared photo');
const shareText = t('share.shareText', 'Check out this moment on Fotospiel.');
const openShareSheet = React.useCallback(async () => {
if (!lightboxPhoto || !token) return;
setShareSheet({ url: null, loading: true });
try {
const payload = await createPhotoShareLink(token, lightboxPhoto.id);
const url = payload?.url ?? null;
setShareSheet({ url, loading: false });
} catch (error) {
console.error('Share failed', error);
pushGuestToast({ text: t('share.error', 'Share failed'), type: 'error' });
setShareSheet({ url: null, loading: false });
}
}, [lightboxPhoto, t, token]);
const closeShareSheet = React.useCallback(() => {
setShareSheet({ url: null, loading: false });
}, []);
const shareWhatsApp = React.useCallback(
(url?: string | null) => {
if (!url) return;
const waUrl = `https://wa.me/?text=${encodeURIComponent(`${shareText} ${url}`)}`;
window.open(waUrl, '_blank', 'noopener');
closeShareSheet();
},
[closeShareSheet, shareText]
);
const shareMessages = React.useCallback(
(url?: string | null) => {
if (!url) return;
const smsUrl = `sms:?&body=${encodeURIComponent(`${shareText} ${url}`)}`;
window.open(smsUrl, '_blank', 'noopener');
closeShareSheet();
},
[closeShareSheet, shareText]
);
const copyLink = React.useCallback(
async (url?: string | null) => {
if (!url) return;
try {
await navigator.clipboard?.writeText(url);
pushGuestToast({ text: t('share.copySuccess', 'Link copied!') });
} catch (error) {
console.error('Copy failed', error);
pushGuestToast({ text: t('share.copyError', 'Link could not be copied.'), type: 'error' });
} finally {
closeShareSheet();
}
},
[closeShareSheet, t]
);
const shareNative = React.useCallback(
(url?: string | null) => {
if (!url) return;
const data: ShareData = {
title: shareTitle,
text: shareText,
url,
};
if (navigator.share && (!navigator.canShare || navigator.canShare(data))) {
navigator.share(data).catch(() => undefined);
closeShareSheet();
return;
}
void copyLink(url);
},
[closeShareSheet, copyLink, shareText, shareTitle]
);
const handleTouchStart = (event: React.TouchEvent) => {
touchStartX.current = event.touches[0]?.clientX ?? null;
};
const handleTouchEnd = (event: React.TouchEvent) => {
if (touchStartX.current === null) {
return;
}
const endX = event.changedTouches[0]?.clientX ?? null;
if (endX === null) {
touchStartX.current = null;
return;
}
const delta = endX - touchStartX.current;
touchStartX.current = null;
if (Math.abs(delta) < 60) {
return;
}
if (delta > 0) {
goPrev();
return;
}
goNext();
};
return (
<AppShell>
<YStack gap="$4">
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
padding="$2"
borderRadius="$bentoLg"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderColor={cardBorder}
gap="$3"
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2"
position="relative"
overflow="hidden"
style={{
boxShadow: cardShadow,
backgroundImage: isDark
? 'radial-gradient(120% 120% at 20% 20%, rgba(56, 189, 248, 0.18), transparent 55%), radial-gradient(120% 120% at 80% 15%, rgba(251, 113, 133, 0.18), transparent 60%)'
: 'radial-gradient(130% 130% at 20% 20%, color-mix(in oklab, var(--guest-primary, #0EA5E9) 25%, white), transparent 55%), radial-gradient(120% 120% at 80% 0%, color-mix(in oklab, var(--guest-secondary, #F43F5E) 18%, white), transparent 60%)',
boxShadow: hardShadow,
}}
>
<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>
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}>
{t('galleryPage.hero.label', 'Live-Galerie')}
</Text>
</XStack>
<XStack alignItems="center" justifyContent="space-between" flexWrap="wrap" gap="$2">
<Text fontSize="$6" fontFamily="$display" fontWeight="$8">
{event?.name ?? t('galleryPage.hero.eventFallback', 'Euer Event')}
</Text>
<Button
size="$3"
backgroundColor={mutedButton}
backgroundColor="$primary"
borderRadius="$pill"
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={() => navigate(uploadPath)}
pressStyle={{ y: 2 }}
style={{
boxShadow: isDark ? '0 8px 0 rgba(2, 6, 23, 0.55)' : '0 8px 0 rgba(15, 23, 42, 0.18)',
}}
>
<Filter size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
{t('galleryPage.hero.upload', 'Neues Foto hochladen')}
</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>
{newUploads > 0 ? (
<YStack
paddingHorizontal="$2.5"
paddingVertical="$1"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
alignSelf="flex-start"
>
<Text fontSize="$1" fontWeight="$6">
{t('galleryPage.feed.newUploads', { count: newUploads }, '{count} neue Uploads sind da.')}
</Text>
</YStack>
) : null}
<Text fontSize="$1" color="$color" opacity={0.7}>
{heroStatsLine}
</Text>
<YStack
gap="$1"
paddingTop="$1"
borderTopWidth={1}
borderTopColor={bentoSurface.borderColor}
>
<XStack gap="$1.5" flexWrap="wrap" justifyContent="center">
{(
[
{ 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="$2"
backgroundColor={filter === chip.value ? '$primary' : mutedButton}
borderRadius="$pill"
borderWidth={1}
borderColor={filter === chip.value ? '$primary' : mutedButtonBorder}
onPress={() => setFilter(chip.value)}
>
<Text fontSize="$1" fontWeight="$6" color={filter === chip.value ? '#FFFFFF' : undefined}>
{chip.label}
</Text>
</Button>
))}
</XStack>
</YStack>
</YStack>
{isEmpty ? (
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderColor={cardBorder}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$3"
alignItems="center"
style={{
@@ -312,18 +620,20 @@ export default function GalleryScreen() {
) : isSingle ? (
<YStack gap="$3">
<Button unstyled onPress={() => openLightbox(displayPhotos[0].id)}>
<PhotoFrameTile height={360} borderRadius="$card">
<PhotoFrameTile height={360} borderRadius="$bento">
<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}')}
alt={t('galleryPage.photo.alt', { id: displayPhotos[0].id, suffix: '' }, `Foto ${displayPhotos[0].id}`)}
loading="eager"
decoding="async"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</YStack>
</PhotoFrameTile>
</Button>
<Button unstyled onPress={() => navigate(uploadPath)}>
<PhotoFrameTile height={160} borderRadius="$card">
<PhotoFrameTile height={160} borderRadius="$bento">
<YStack flex={1} alignItems="center" justifyContent="center" gap="$2" padding="$3">
<YStack
width={48}
@@ -353,6 +663,7 @@ export default function GalleryScreen() {
if (typeof tile === 'number') {
return <PhotoFrameTile key={`left-${tile}`} height={140 + (index % 3) * 24} shimmer shimmerDelayMs={200 + index * 120} />;
}
const altText = t('galleryPage.photo.alt', { id: tile.id, suffix: '' }, `Foto ${tile.id}`);
return (
<Button
key={tile.id}
@@ -363,8 +674,10 @@ export default function GalleryScreen() {
<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' }}
alt={altText}
loading={index < 4 ? 'eager' : 'lazy'}
decoding="async"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</YStack>
</PhotoFrameTile>
@@ -377,6 +690,7 @@ export default function GalleryScreen() {
if (typeof tile === 'number') {
return <PhotoFrameTile key={`right-${tile}`} height={120 + (index % 3) * 28} shimmer shimmerDelayMs={260 + index * 140} />;
}
const altText = t('galleryPage.photo.alt', { id: tile.id, suffix: '' }, `Foto ${tile.id}`);
return (
<Button
key={tile.id}
@@ -387,8 +701,10 @@ export default function GalleryScreen() {
<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' }}
alt={altText}
loading={index < 4 ? 'eager' : 'lazy'}
decoding="async"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</YStack>
</PhotoFrameTile>
@@ -424,27 +740,254 @@ export default function GalleryScreen() {
</XStack>
)}
</YStack>
{lightboxOpen ? (
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$1"
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
zIndex={2000}
padding="$3"
alignItems="center"
justifyContent="center"
style={{
boxShadow: cardShadow,
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.75)' : 'rgba(15, 23, 42, 0.45)',
backdropFilter: 'blur(12px)',
}}
>
<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>
<Button
unstyled
onPress={closeLightbox}
style={{ position: 'absolute', inset: 0, zIndex: 0 }}
aria-label={t('common.actions.close', 'Close')}
/>
<YStack
width="100%"
maxWidth={780}
gap="$2"
padding="$3"
borderRadius="$bentoLg"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
style={{ boxShadow: hardShadow, zIndex: 1 }}
>
<XStack alignItems="center" justifyContent="space-between">
<YStack gap="$1">
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.1}>
{t('galleryPage.hero.label', 'Live-Galerie')}
</Text>
<Text fontSize="$4" fontWeight="$7">
{event?.name ?? t('galleryPage.hero.eventFallback', 'Euer Event')}
</Text>
</YStack>
<Button
size="$3"
circular
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={closeLightbox}
aria-label={t('common.actions.close', 'Close')}
>
<X size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
<YStack
borderRadius="$bento"
backgroundColor="$muted"
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
overflow="hidden"
style={{ height: 'min(70vh, 520px)', boxShadow: cardShadow }}
>
<YStack flex={1} alignItems="center" justifyContent="center" padding="$2">
{lightboxPhoto ? (
<YStack
flex={1}
width="100%"
height="100%"
position="relative"
alignItems="center"
justifyContent="center"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
style={{ zIndex: 0 }}
>
<img
src={lightboxPhoto.imageUrl}
alt={t('galleryPage.photo.alt', { id: lightboxPhoto.id, suffix: '' }, `Foto ${lightboxPhoto.id}`)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
position: 'absolute',
inset: 0,
zIndex: 0,
pointerEvents: 'none',
}}
/>
<Button
unstyled
onPress={handleLike}
aria-label={t('galleryPage.photo.likeAria', 'Like')}
style={{
position: 'absolute',
right: 14,
bottom: 14,
zIndex: 10,
}}
>
<YStack
padding="$2.5"
borderRadius="$pill"
backgroundColor={likedIds.has(lightboxPhoto.id) ? '#F43F5E' : mutedButton}
borderWidth={1}
borderColor={likedIds.has(lightboxPhoto.id) ? '#F43F5E' : mutedButtonBorder}
alignItems="center"
justifyContent="center"
style={{
boxShadow: isDark ? '0 8px 0 rgba(2, 6, 23, 0.55)' : '0 8px 0 rgba(15, 23, 42, 0.18)',
}}
>
<Heart size={22} color={likedIds.has(lightboxPhoto.id) ? '#FFFFFF' : (isDark ? '#F8FAFF' : '#0F172A')} />
</YStack>
</Button>
</YStack>
) : (
<Text fontSize="$2" color="$color" opacity={0.7}>
{lightboxLoading ? t('galleryPage.loading', 'Loading…') : t('lightbox.errors.notFound', 'Photo not found')}
</Text>
)}
</YStack>
<XStack
alignItems="center"
justifyContent="space-between"
padding="$2"
borderTopWidth={1}
borderColor={bentoSurface.borderColor}
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.35)' : 'rgba(255, 255, 255, 0.8)'}
>
<Text fontSize="$1" color="$color" opacity={0.7}>
{lightboxIndex >= 0 ? `${lightboxIndex + 1} / ${displayPhotos.length}` : ''}
</Text>
<XStack
gap="$1"
padding="$1"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
alignItems="center"
>
<Button
unstyled
paddingHorizontal="$2.5"
paddingVertical="$1.5"
onPress={goPrev}
disabled={lightboxIndex <= 0}
opacity={lightboxIndex <= 0 ? 0.4 : 1}
>
<XStack alignItems="center" gap="$1.5">
<ChevronLeft size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$1" fontWeight="$6">
{t('galleryPage.lightbox.prev', 'Prev')}
</Text>
</XStack>
</Button>
<Button
unstyled
paddingHorizontal="$2.5"
paddingVertical="$1.5"
onPress={goNext}
disabled={lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1}
opacity={lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1 ? 0.4 : 1}
>
<XStack alignItems="center" gap="$1.5">
<Text fontSize="$1" fontWeight="$6">
{t('galleryPage.lightbox.next', 'Next')}
</Text>
<ChevronRight size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
</XStack>
</Button>
</XStack>
</XStack>
</YStack>
<XStack alignItems="center" justifyContent="space-between" flexWrap="wrap" gap="$2">
<Text fontSize="$3" fontWeight="$7">
{lightboxPhoto
? t(
'galleryPage.lightbox.likes',
{ count: likesById[lightboxPhoto.id] ?? lightboxPhoto.likes },
'{count} likes'
)
: ''}
</Text>
<XStack
gap="$1"
padding="$1"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
alignItems="center"
flexWrap="wrap"
justifyContent="flex-end"
>
<Button unstyled onPress={openShareSheet} paddingHorizontal="$3" paddingVertical="$2">
<XStack alignItems="center" gap="$2">
<Share2 size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{shareSheet.loading ? t('share.loading', 'Sharing...') : t('share.button', 'Share')}
</Text>
</XStack>
</Button>
</XStack>
</XStack>
</YStack>
</YStack>
</YStack>
) : null}
<ShareSheet
open={shareSheet.loading || Boolean(shareSheet.url)}
onOpenChange={(open) => {
if (!open) {
closeShareSheet();
}
}}
photoId={lightboxPhoto?.id}
eventName={event?.name ?? null}
url={shareSheet.url}
loading={shareSheet.loading}
onShareNative={() => shareNative(shareSheet.url)}
onShareWhatsApp={() => shareWhatsApp(shareSheet.url)}
onShareMessages={() => shareMessages(shareSheet.url)}
onCopyLink={() => copyLink(shareSheet.url)}
/>
</AppShell>
);
}
function mapFullPhoto(photo: Record<string, unknown>): LightboxPhoto | null {
const id = Number(photo.id ?? 0);
if (!id) return null;
const imageUrl = normalizeImageUrl(
(photo.full_url as string | null | undefined)
?? (photo.file_path as string | null | undefined)
?? (photo.thumbnail_url as string | null | undefined)
?? (photo.thumbnail_path as string | null | undefined)
?? (photo.url as string | null | undefined)
?? (photo.image_url as string | null | undefined)
);
if (!imageUrl) return null;
return {
id,
imageUrl,
likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0,
};
}

View File

@@ -367,7 +367,7 @@ export default function TasksScreen() {
<XStack alignItems="center" gap="$2">
<Play size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$7">
{t('tasks.startTask', 'Start task')}
{t('tasks.startTask', 'Aufgabe starten')}
</Text>
</XStack>
</Button>

View File

@@ -14,8 +14,8 @@ import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services/pendingUploadsApi';
import { resolveUploadErrorDialog, type UploadErrorDialog } from '@/guest/lib/uploadErrorDialog';
import { fetchTasks, type TaskItem } from '../services/tasksApi';
import SurfaceCard from '../components/SurfaceCard';
import { pushGuestToast } from '../lib/toast';
import { getBentoSurfaceTokens } from '../lib/bento';
function getTaskValue(task: TaskItem, key: string): string | undefined {
const value = task?.[key as keyof TaskItem];
@@ -51,8 +51,12 @@ export default function UploadScreen() {
const [previewFile, setPreviewFile] = React.useState<File | null>(null);
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
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 bentoSurface = getBentoSurfaceTokens(isDark);
const cardBorder = bentoSurface.borderColor;
const cardShadow = bentoSurface.shadow;
const hardShadow = isDark
? '0 18px 0 rgba(2, 6, 23, 0.55), 0 32px 40px rgba(2, 6, 23, 0.55)'
: '0 18px 0 rgba(15, 23, 42, 0.22), 0 30px 36px rgba(15, 23, 42, 0.2)';
const iconColor = isDark ? '#F8FAFF' : '#0F172A';
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)';
@@ -448,7 +452,19 @@ export default function UploadScreen() {
<AppShell>
<YStack gap="$4">
{taskId ? (
<SurfaceCard>
<YStack
padding="$3"
borderRadius="$bentoLg"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2"
style={{
boxShadow: hardShadow,
}}
>
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color={iconColor} />
<Text fontSize="$3" fontWeight="$7">
@@ -475,27 +491,21 @@ export default function UploadScreen() {
{taskError}
</Text>
) : null}
</SurfaceCard>
</YStack>
) : null}
<YStack
borderRadius="$card"
backgroundColor="$muted"
borderRadius="$bentoLg"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderColor={cardBorder}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
overflow="hidden"
style={{
backgroundImage: isDark
? 'linear-gradient(135deg, rgba(15, 23, 42, 0.7), rgba(8, 12, 24, 0.9)), radial-gradient(circle at 20% 20%, rgba(255, 79, 216, 0.2), transparent 50%)'
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 255, 0.82)), radial-gradient(circle at 20% 20%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 16%, white), transparent 60%)',
boxShadow: isExpanded
? isDark
? '0 28px 60px rgba(2, 6, 23, 0.55)'
: '0 22px 44px rgba(15, 23, 42, 0.16)'
: isDark
? '0 22px 40px rgba(2, 6, 23, 0.5)'
: '0 18px 32px rgba(15, 23, 42, 0.12)',
borderRadius: isExpanded ? 28 : undefined,
transition: 'box-shadow 360ms ease, border-radius 360ms ease',
? 'radial-gradient(120% 120% at 12% 15%, rgba(56, 189, 248, 0.18), transparent 55%), radial-gradient(130% 130% at 88% 10%, rgba(251, 113, 133, 0.2), transparent 60%)'
: 'radial-gradient(120% 120% at 12% 15%, color-mix(in oklab, var(--guest-primary, #0EA5E9) 18%, white), transparent 55%), radial-gradient(130% 130% at 88% 10%, color-mix(in oklab, var(--guest-secondary, #F43F5E) 18%, white), transparent 60%)',
boxShadow: hardShadow,
}}
>
<YStack
@@ -697,61 +707,86 @@ export default function UploadScreen() {
</YStack>
) : null}
</YStack>
<XStack
gap="$2"
padding={isExpanded ? '$2' : '$3'}
alignItems="center"
justifyContent="space-between"
borderTopWidth={1}
borderColor={cardBorder}
backgroundColor={isDark ? 'rgba(10, 14, 28, 0.7)' : 'rgba(255, 255, 255, 0.75)'}
>
{cameraState === 'preview' ? null : (
<>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={handlePick}
paddingHorizontal="$4"
gap="$2"
alignSelf="center"
flexShrink={0}
justifyContent="center"
>
<Image size={16} color={iconColor} />
<Text fontSize="$2" fontWeight="$6">
{t('uploadV2.galleryCta', 'Upload from gallery')}
</Text>
</Button>
{facingMode === 'user' ? (
<Button
size="$3"
circular
backgroundColor={mirror ? '$primary' : mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={() => setMirror((prev) => !prev)}
>
<FlipHorizontal size={16} color={mirror ? '#FFFFFF' : iconColor} />
</Button>
) : null}
</>
)}
</XStack>
</YStack>
{cameraState === 'preview' ? null : (
<XStack gap="$2">
<Button
flex={1}
height={64}
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
onPress={() => {
if (cameraState === 'ready') {
void handleCapture();
return;
}
void startCamera();
}}
disabled={cameraState === 'starting' || cameraState === 'blocked' || cameraState === 'unsupported'}
style={{ boxShadow: cardShadow }}
>
<XStack alignItems="center" gap="$2">
<Camera size={18} color={iconColor} />
<Text fontSize="$3" fontWeight="$7">
{cameraState === 'ready'
? t('upload.captureButton', 'Foto aufnehmen')
: cameraState === 'starting'
? t('upload.buttons.starting', 'Kamera startet…')
: t('upload.buttons.startCamera', 'Kamera starten')}
</Text>
</XStack>
</Button>
<Button
flex={1}
height={64}
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
onPress={handlePick}
style={{ boxShadow: cardShadow }}
>
<XStack alignItems="center" gap="$2">
<Image size={18} color={iconColor} />
<Text fontSize="$3" fontWeight="$7">
{t('uploadV2.galleryCta', 'Aus Galerie')}
</Text>
</XStack>
</Button>
{facingMode === 'user' ? (
<Button
size="$3"
circular
backgroundColor={mirror ? '$primary' : mutedButton}
borderWidth={1}
borderBottomWidth={3}
borderColor={mutedButtonBorder}
borderBottomColor={mutedButtonBorder}
onPress={() => setMirror((prev) => !prev)}
>
<FlipHorizontal size={16} color={mirror ? '#FFFFFF' : iconColor} />
</Button>
) : null}
</XStack>
)}
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderRadius="$bentoLg"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderColor={cardBorder}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2"
style={{
boxShadow: cardShadow,
boxShadow: hardShadow,
}}
>
<Text fontSize="$4" fontWeight="$7">

View File

@@ -1,6 +1,6 @@
import { fetchJson } from './apiClient';
import { getDeviceId } from '../lib/device';
export { likePhoto, createPhotoShareLink, uploadPhoto } from '@/guest/services/photosApi';
export { likePhoto, unlikePhoto, createPhotoShareLink, uploadPhoto } from '@/guest/services/photosApi';
export type GalleryPhoto = Record<string, unknown>;