upgrade to tamagui v2 and guest pwa overhaul
This commit is contained in:
589
resources/js/guest-v2/screens/PhotoLightboxScreen.tsx
Normal file
589
resources/js/guest-v2/screens/PhotoLightboxScreen.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
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, 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 { 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 { useAppearance } from '@/hooks/use-appearance';
|
||||
import { buildEventPath } from '../lib/routes';
|
||||
|
||||
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 } = useEventData();
|
||||
const { photoId } = useParams<{ photoId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { locale } = useLocale();
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
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 [shareStatus, setShareStatus] = React.useState<'idle' | 'loading' | 'copied' | 'failed'>('idle');
|
||||
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, token]);
|
||||
|
||||
const handleShare = React.useCallback(async () => {
|
||||
if (!selected || !token) return;
|
||||
setShareStatus('loading');
|
||||
try {
|
||||
const payload = await createPhotoShareLink(token, selected.id);
|
||||
const url = payload?.url ?? '';
|
||||
if (!url) {
|
||||
throw new Error('missing share url');
|
||||
}
|
||||
const data: ShareData = {
|
||||
title: t('share.defaultEvent', 'A special moment'),
|
||||
text: t('share.shareText', 'Check out this moment on Fotospiel.'),
|
||||
url,
|
||||
};
|
||||
if (navigator.share && (!navigator.canShare || navigator.canShare(data))) {
|
||||
await navigator.share(data);
|
||||
setShareStatus('idle');
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard?.writeText(url);
|
||||
setShareStatus('copied');
|
||||
} catch (error) {
|
||||
console.error('Share failed', error);
|
||||
setShareStatus('failed');
|
||||
} finally {
|
||||
window.setTimeout(() => setShareStatus('idle'), 2000);
|
||||
}
|
||||
}, [selected, t, token]);
|
||||
|
||||
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>
|
||||
<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={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={handleShare}
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$2"
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Share2 size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{shareStatus === 'loading'
|
||||
? t('share.loading', 'Sharing...')
|
||||
: shareStatus === 'copied'
|
||||
? t('share.copySuccess', 'Copied')
|
||||
: shareStatus === 'failed'
|
||||
? t('share.copyError', 'Copy failed')
|
||||
: 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>
|
||||
)}
|
||||
</SurfaceCard>
|
||||
</YStack>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user