Files
fotospiel-app/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx
Codex Agent c6aaf859f5
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Add emotion data and lightbox share/download
2026-02-05 20:35:11 +01:00

675 lines
23 KiB
TypeScript

import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { ArrowLeft, ChevronLeft, ChevronRight, Download, Heart, Share2 } from 'lucide-react';
import { useGesture } from '@use-gesture/react';
import { animated, to, useSpring } from '@react-spring/web';
import AppShell from '../components/AppShell';
import SurfaceCard from '../components/SurfaceCard';
import ShareSheet from '../components/ShareSheet';
import { useEventData } from '../context/EventDataContext';
import { fetchGallery, fetchPhoto, likePhoto, createPhotoShareLink } from '../services/photosApi';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { buildEventPath } from '../lib/routes';
import { pushGuestToast } from '../lib/toast';
type LightboxPhoto = {
id: number;
imageUrl: string;
likes: number;
createdAt?: string | null;
ingestSource?: string | null;
};
function normalizeImageUrl(src?: string | null) {
if (!src) {
return '';
}
if (/^https?:/i.test(src)) {
return src;
}
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
if (!cleanPath.startsWith('storage/')) {
cleanPath = `storage/${cleanPath}`;
}
return `/${cleanPath}`.replace(/\/+/g, '/');
}
function mapPhoto(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,
createdAt: typeof photo.created_at === 'string' ? photo.created_at : null,
ingestSource: typeof photo.ingest_source === 'string' ? photo.ingest_source : null,
};
}
export default function PhotoLightboxScreen() {
const { token, event } = useEventData();
const { photoId } = useParams<{ photoId: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const { locale } = useLocale();
const { isDark } = useGuestThemeVariant();
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
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 groupBackground = isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(15, 23, 42, 0.04)';
const [photos, setPhotos] = React.useState<LightboxPhoto[]>([]);
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
const [cursor, setCursor] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(false);
const [loadingMore, setLoadingMore] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [likes, setLikes] = React.useState<Record<number, number>>({});
const [shareSheet, setShareSheet] = React.useState<{ url: string | null; loading: boolean }>({
url: null,
loading: false,
});
const zoomContainerRef = React.useRef<HTMLDivElement | null>(null);
const zoomImageRef = React.useRef<HTMLImageElement | null>(null);
const baseSizeRef = React.useRef({ width: 0, height: 0 });
const scaleRef = React.useRef(1);
const lastTapRef = React.useRef(0);
const [isZoomed, setIsZoomed] = React.useState(false);
const [{ x, y, scale }, api] = useSpring(() => ({
x: 0,
y: 0,
scale: 1,
config: { tension: 260, friction: 28 },
}));
const selected = selectedIndex !== null ? photos[selectedIndex] : null;
const loadPage = React.useCallback(
async (nextCursor?: string | null, replace = false) => {
if (!token) return { items: [], nextCursor: null };
const response = await fetchGallery(token, { cursor: nextCursor ?? undefined, limit: 28, locale });
const mapped = (response.data ?? [])
.map((photo) => mapPhoto(photo as Record<string, unknown>))
.filter(Boolean) as LightboxPhoto[];
setCursor(response.next_cursor ?? null);
setPhotos((prev) => (replace ? mapped : [...prev, ...mapped]));
setLikes((prev) => {
const next = { ...prev };
for (const item of mapped) {
if (next[item.id] === undefined) {
next[item.id] = item.likes;
}
}
return next;
});
return { items: mapped, nextCursor: response.next_cursor ?? null };
},
[locale, token]
);
React.useEffect(() => {
if (!token || !photoId) {
setError(t('lightbox.errors.notFound', 'Photo not found'));
return;
}
let active = true;
const targetId = Number(photoId);
const init = async () => {
setLoading(true);
setError(null);
try {
let foundIndex = -1;
let nextCursor: string | null = null;
let combined: LightboxPhoto[] = [];
for (let page = 0; page < 5; page += 1) {
const response = await fetchGallery(token, {
cursor: nextCursor ?? undefined,
limit: 28,
locale,
});
const mapped = (response.data ?? [])
.map((photo) => mapPhoto(photo as Record<string, unknown>))
.filter(Boolean) as LightboxPhoto[];
combined = [...combined, ...mapped];
nextCursor = response.next_cursor ?? null;
foundIndex = combined.findIndex((item) => item.id === targetId);
if (foundIndex >= 0 || !nextCursor) {
break;
}
}
if (!active) return;
if (combined.length > 0) {
setPhotos(combined);
setCursor(nextCursor);
setLikes((prev) => {
const next = { ...prev };
for (const item of combined) {
if (next[item.id] === undefined) {
next[item.id] = item.likes;
}
}
return next;
});
}
if (foundIndex >= 0) {
setSelectedIndex(foundIndex);
return;
}
const single = await fetchPhoto(targetId, locale);
if (!active) return;
const mappedSingle = single ? mapPhoto(single as Record<string, unknown>) : null;
if (mappedSingle) {
setPhotos([mappedSingle]);
setSelectedIndex(0);
return;
}
setError(t('lightbox.errors.notFound', 'Photo not found'));
} catch (err) {
if (!active) return;
console.error('Photo lightbox load failed', err);
setError(t('lightbox.errors.loadFailed', 'Failed to load photo'));
} finally {
if (active) setLoading(false);
}
};
init();
return () => {
active = false;
};
}, [photoId, token, locale, t]);
const updateBaseSize = React.useCallback(() => {
if (!zoomImageRef.current) {
return;
}
const rect = zoomImageRef.current.getBoundingClientRect();
baseSizeRef.current = { width: rect.width, height: rect.height };
}, []);
React.useEffect(() => {
updateBaseSize();
}, [selected?.id, updateBaseSize]);
React.useEffect(() => {
window.addEventListener('resize', updateBaseSize);
return () => window.removeEventListener('resize', updateBaseSize);
}, [updateBaseSize]);
const clamp = React.useCallback((value: number, min: number, max: number) => {
return Math.min(max, Math.max(min, value));
}, []);
const getBounds = React.useCallback(
(nextScale: number) => {
const container = zoomContainerRef.current?.getBoundingClientRect();
const { width, height } = baseSizeRef.current;
if (!container || !width || !height) {
return { maxX: 0, maxY: 0 };
}
const scaledWidth = width * nextScale;
const scaledHeight = height * nextScale;
const maxX = Math.max(0, (scaledWidth - container.width) / 2);
const maxY = Math.max(0, (scaledHeight - container.height) / 2);
return { maxX, maxY };
},
[]
);
const resetZoom = React.useCallback(() => {
scaleRef.current = 1;
setIsZoomed(false);
api.start({ x: 0, y: 0, scale: 1 });
}, [api]);
React.useEffect(() => {
resetZoom();
}, [selected?.id, resetZoom]);
const toggleZoom = React.useCallback(() => {
const nextScale = scaleRef.current > 1.01 ? 1 : 2;
scaleRef.current = nextScale;
setIsZoomed(nextScale > 1.01);
api.start({ x: 0, y: 0, scale: nextScale });
}, [api]);
const goPrev = React.useCallback(() => {
if (selectedIndex === null || selectedIndex <= 0) return;
setSelectedIndex(selectedIndex - 1);
}, [selectedIndex]);
const goNext = React.useCallback(async () => {
if (selectedIndex === null) return;
if (selectedIndex < photos.length - 1) {
setSelectedIndex(selectedIndex + 1);
return;
}
if (!cursor || loadingMore) {
return;
}
setLoadingMore(true);
try {
const { items } = await loadPage(cursor);
if (items.length > 0) {
setSelectedIndex((prev) => (typeof prev === 'number' ? prev + 1 : prev));
}
} finally {
setLoadingMore(false);
}
}, [cursor, loadPage, loadingMore, photos.length, selectedIndex]);
const handleLike = React.useCallback(async () => {
if (!selected || !token) return;
setLikes((prev) => ({ ...prev, [selected.id]: (prev[selected.id] ?? selected.likes) + 1 }));
try {
const count = await likePhoto(selected.id);
setLikes((prev) => ({ ...prev, [selected.id]: count }));
} catch (error) {
console.error('Like failed', error);
}
}, [selected, t, token]);
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 (!selected || !token) return;
setShareSheet({ url: null, loading: true });
try {
const payload = await createPhotoShareLink(token, selected.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 });
}
}, [selected, 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 downloadPhoto = React.useCallback((url?: string | null, id?: number | null) => {
if (!url || !id) return;
const link = document.createElement('a');
link.href = url;
link.download = `photo-${id}.jpg`;
link.rel = 'noreferrer';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, []);
const bind = useGesture(
{
onDrag: ({ down, movement: [mx, my], offset: [ox, oy], last, event }) => {
if (event.cancelable) {
event.preventDefault();
}
const zoomed = scaleRef.current > 1.01;
if (!zoomed) {
api.start({ x: down ? mx : 0, y: 0, immediate: down });
if (last) {
api.start({ x: 0, y: 0, immediate: false });
const threshold = 80;
if (Math.abs(mx) > threshold) {
if (mx > 0) {
goPrev();
} else {
void goNext();
}
}
}
return;
}
const { maxX, maxY } = getBounds(scaleRef.current);
api.start({
x: clamp(ox, -maxX, maxX),
y: clamp(oy, -maxY, maxY),
immediate: down,
});
},
onPinch: ({ offset: [nextScale], last, event }) => {
if (event.cancelable) {
event.preventDefault();
}
const clampedScale = clamp(nextScale, 1, 3);
scaleRef.current = clampedScale;
setIsZoomed(clampedScale > 1.01);
const { maxX, maxY } = getBounds(clampedScale);
api.start({
scale: clampedScale,
x: clamp(x.get(), -maxX, maxX),
y: clamp(y.get(), -maxY, maxY),
immediate: true,
});
if (last && clampedScale <= 1.01) {
resetZoom();
}
},
},
{
drag: {
from: () => [x.get(), y.get()],
filterTaps: true,
threshold: 4,
},
pinch: {
scaleBounds: { min: 1, max: 3 },
rubberband: true,
},
eventOptions: { passive: false },
}
);
const handlePointerUp = (event: React.PointerEvent) => {
if (event.pointerType !== 'touch') {
return;
}
const now = Date.now();
if (now - lastTapRef.current < 280) {
lastTapRef.current = 0;
toggleZoom();
return;
}
lastTapRef.current = now;
};
return (
<AppShell>
<YStack gap="$4">
<SurfaceCard position="relative">
<XStack alignItems="center" justifyContent="space-between">
<Button
size="$3"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={() => navigate(buildEventPath(token, '/gallery'))}
paddingHorizontal="$3"
aria-label={t('common.actions.close', 'Close')}
>
<ArrowLeft size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
<Text fontSize="$4" fontWeight="$7">
{t('galleryPage.title', 'Gallery')}
</Text>
<XStack width={48} />
</XStack>
</SurfaceCard>
<SurfaceCard padding="$4">
{loading ? (
<Text fontSize="$3" color="$color" opacity={0.7}>
{t('galleryPage.loading', 'Loading…')}
</Text>
) : error ? (
<Text fontSize="$3" color="#FCA5A5">
{error}
</Text>
) : selected ? (
<YStack gap="$3">
<YStack
borderRadius="$card"
backgroundColor="$muted"
borderWidth={1}
borderColor={cardBorder}
overflow="hidden"
alignItems="center"
justifyContent="center"
style={{ height: 'min(70vh, 520px)' }}
>
<div
ref={zoomContainerRef}
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
touchAction: 'none',
}}
{...bind()}
onPointerUp={handlePointerUp}
>
<animated.div
style={{
transform: to([x, y, scale], (nx, ny, ns) => `translate3d(${nx}px, ${ny}px, 0) scale(${ns})`),
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
ref={zoomImageRef}
src={selected.imageUrl}
alt={t('galleryPage.photo.alt', { id: selected.id }, 'Photo {id}')}
onLoad={updateBaseSize}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
userSelect: 'none',
pointerEvents: isZoomed ? 'auto' : 'none',
}}
/>
</animated.div>
</div>
</YStack>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$2" color="$color" opacity={0.7}>
{selectedIndex !== null ? `${selectedIndex + 1} / ${photos.length}` : ''}
</Text>
<XStack
gap="$1"
padding="$1"
borderRadius="$pill"
backgroundColor={groupBackground}
borderWidth={1}
borderColor={mutedButtonBorder}
alignItems="center"
flexWrap="wrap"
justifyContent="flex-end"
>
<Button
unstyled
disabled={selectedIndex === null || selectedIndex <= 0}
onPress={goPrev}
paddingHorizontal="$3"
paddingVertical="$2"
opacity={selectedIndex === null || selectedIndex <= 0 ? 0.4 : 1}
>
<XStack alignItems="center" gap="$2">
<ChevronLeft size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{t('galleryPage.lightbox.prev', 'Prev')}
</Text>
</XStack>
</Button>
<Button
unstyled
disabled={selectedIndex === null || (selectedIndex >= photos.length - 1 && !cursor)}
onPress={goNext}
paddingHorizontal="$3"
paddingVertical="$2"
opacity={selectedIndex === null || (selectedIndex >= photos.length - 1 && !cursor) ? 0.4 : 1}
>
<XStack alignItems="center" gap="$2">
<Text fontSize="$2" fontWeight="$6">
{loadingMore ? t('common.actions.loading', 'Loading...') : t('galleryPage.lightbox.next', 'Next')}
</Text>
<ChevronRight size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</XStack>
</Button>
</XStack>
</XStack>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$4" fontWeight="$7">
{t('galleryPage.lightbox.likes', { count: likes[selected.id] ?? selected.likes }, '{count} likes')}
</Text>
<XStack
gap="$1"
padding="$1"
borderRadius="$pill"
backgroundColor={groupBackground}
borderWidth={1}
borderColor={mutedButtonBorder}
alignItems="center"
flexWrap="wrap"
justifyContent="flex-end"
>
<Button
unstyled
onPress={() => downloadPhoto(selected?.imageUrl ?? null, selected?.id ?? null)}
paddingHorizontal="$3"
paddingVertical="$2"
>
<XStack alignItems="center" gap="$2">
<Download size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{t('common.actions.download', 'Download')}
</Text>
</XStack>
</Button>
<Button
unstyled
onPress={handleLike}
paddingHorizontal="$3"
paddingVertical="$2"
>
<XStack alignItems="center" gap="$2">
<Heart size={16} color="#FFFFFF" />
<Text fontSize="$2" fontWeight="$6" color="#FFFFFF">
{t('galleryPage.photo.likeAria', 'Like')}
</Text>
</XStack>
</Button>
<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>
) : (
<Text fontSize="$3" color="$color" opacity={0.7}>
{t('lightbox.errors.notFound', 'Photo not found')}
</Text>
)}
<ShareSheet
open={shareSheet.loading || Boolean(shareSheet.url)}
onOpenChange={(open) => {
if (!open) {
closeShareSheet();
}
}}
photoId={selected?.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)}
variant="inline"
/>
</SurfaceCard>
</YStack>
</AppShell>
);
}