675 lines
23 KiB
TypeScript
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>
|
|
);
|
|
}
|