upgrade to tamagui v2 and guest pwa overhaul
This commit is contained in:
347
resources/js/guest-v2/screens/GalleryScreen.tsx
Normal file
347
resources/js/guest-v2/screens/GalleryScreen.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import React from 'react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { Image as ImageIcon, Filter } from 'lucide-react';
|
||||
import AppShell from '../components/AppShell';
|
||||
import PhotoFrameTile from '../components/PhotoFrameTile';
|
||||
import { useEventData } from '../context/EventDataContext';
|
||||
import { fetchGallery } from '../services/photosApi';
|
||||
import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { buildEventPath } from '../lib/routes';
|
||||
|
||||
type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
|
||||
|
||||
type GalleryTile = {
|
||||
id: number;
|
||||
imageUrl: string;
|
||||
likes: number;
|
||||
createdAt?: string | null;
|
||||
ingestSource?: string | null;
|
||||
sessionId?: 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, '/');
|
||||
}
|
||||
|
||||
export default function GalleryScreen() {
|
||||
const { token } = useEventData();
|
||||
const { t } = useTranslation();
|
||||
const { locale } = useLocale();
|
||||
const navigate = useNavigate();
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
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 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 [filter, setFilter] = React.useState<GalleryFilter>('latest');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!token) {
|
||||
setPhotos([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
|
||||
fetchGallery(token, { limit: 18, locale })
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
const list = Array.isArray(response.data) ? response.data : [];
|
||||
const mapped = list
|
||||
.map((photo) => {
|
||||
const record = photo as Record<string, unknown>;
|
||||
const id = Number(record.id ?? 0);
|
||||
const likesCount = typeof record.likes_count === 'number' ? record.likes_count : 0;
|
||||
const imageUrl = normalizeImageUrl(
|
||||
(record.thumbnail_url as string | null | undefined)
|
||||
?? (record.thumbnail_path as string | null | undefined)
|
||||
?? (record.file_path as string | null | undefined)
|
||||
?? (record.full_url as string | null | undefined)
|
||||
?? (record.url as string | null | undefined)
|
||||
?? (record.image_url as string | null | undefined)
|
||||
);
|
||||
return {
|
||||
id,
|
||||
imageUrl,
|
||||
likes: likesCount,
|
||||
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
|
||||
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
|
||||
sessionId: typeof record.session_id === 'string' ? record.session_id : null,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.id && item.imageUrl);
|
||||
setPhotos(mapped);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load gallery', error);
|
||||
if (active) {
|
||||
setPhotos([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token, locale]);
|
||||
|
||||
const myPhotoIds = React.useMemo(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('my-photo-ids');
|
||||
return new Set<number>(raw ? JSON.parse(raw) : []);
|
||||
} catch {
|
||||
return new Set<number>();
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const filteredPhotos = React.useMemo(() => {
|
||||
let list = photos.slice();
|
||||
if (filter === 'popular') {
|
||||
list.sort((a, b) => (b.likes ?? 0) - (a.likes ?? 0));
|
||||
} else if (filter === 'mine') {
|
||||
list = list.filter((photo) => myPhotoIds.has(photo.id));
|
||||
} else if (filter === 'photobooth') {
|
||||
list = list.filter((photo) => photo.ingestSource === 'photobooth');
|
||||
list.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime());
|
||||
} else {
|
||||
list.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime());
|
||||
}
|
||||
return list;
|
||||
}, [filter, myPhotoIds, photos]);
|
||||
|
||||
const displayPhotos = filteredPhotos;
|
||||
const leftColumn = displayPhotos.filter((_, index) => index % 2 === 0);
|
||||
const rightColumn = displayPhotos.filter((_, index) => index % 2 === 1);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (filter === 'photobooth' && !photos.some((photo) => photo.ingestSource === 'photobooth')) {
|
||||
setFilter('latest');
|
||||
}
|
||||
}, [filter, photos]);
|
||||
const newUploads = React.useMemo(() => {
|
||||
if (delta.photos.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const existing = new Set(photos.map((item) => item.id));
|
||||
return delta.photos.reduce((count, photo) => {
|
||||
const id = Number((photo as Record<string, unknown>).id ?? 0);
|
||||
if (id && !existing.has(id)) {
|
||||
return count + 1;
|
||||
}
|
||||
return count;
|
||||
}, 0);
|
||||
}, [delta.photos, photos]);
|
||||
const openLightbox = React.useCallback(
|
||||
(photoId: number) => {
|
||||
if (!token) return;
|
||||
navigate(buildEventPath(token, `/photo/${photoId}`));
|
||||
},
|
||||
[navigate, token]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (delta.photos.length === 0) {
|
||||
return;
|
||||
}
|
||||
setPhotos((prev) => {
|
||||
const existing = new Set(prev.map((item) => item.id));
|
||||
const mapped = delta.photos
|
||||
.map((photo) => {
|
||||
const record = photo as Record<string, unknown>;
|
||||
const id = Number(record.id ?? 0);
|
||||
const likesCount = typeof record.likes_count === 'number' ? record.likes_count : 0;
|
||||
const imageUrl = normalizeImageUrl(
|
||||
(record.thumbnail_url as string | null | undefined)
|
||||
?? (record.thumbnail_path as string | null | undefined)
|
||||
?? (record.file_path as string | null | undefined)
|
||||
?? (record.full_url as string | null | undefined)
|
||||
?? (record.url as string | null | undefined)
|
||||
?? (record.image_url as string | null | undefined)
|
||||
);
|
||||
if (!id || !imageUrl || existing.has(id)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
imageUrl,
|
||||
likes: likesCount,
|
||||
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
|
||||
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
|
||||
sessionId: typeof record.session_id === 'string' ? record.session_id : null,
|
||||
} satisfies GalleryTile;
|
||||
})
|
||||
.filter(Boolean) as GalleryTile[];
|
||||
if (mapped.length === 0) {
|
||||
return prev;
|
||||
}
|
||||
return [...mapped, ...prev];
|
||||
});
|
||||
}, [delta.photos]);
|
||||
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<YStack gap="$4">
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
gap="$3"
|
||||
style={{
|
||||
boxShadow: cardShadow,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<Button
|
||||
size="$3"
|
||||
backgroundColor={mutedButton}
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor={mutedButtonBorder}
|
||||
>
|
||||
<Filter size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
</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>
|
||||
</YStack>
|
||||
|
||||
<XStack gap="$3">
|
||||
<YStack flex={1} gap="$3">
|
||||
{(loading || leftColumn.length === 0 ? Array.from({ length: 5 }, (_, index) => index) : leftColumn).map(
|
||||
(tile, index) => {
|
||||
if (typeof tile === 'number') {
|
||||
return <PhotoFrameTile key={`left-${tile}`} height={140 + (index % 3) * 24} shimmer shimmerDelayMs={200 + index * 120} />;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={tile.id}
|
||||
unstyled
|
||||
onPress={() => openLightbox(tile.id)}
|
||||
>
|
||||
<PhotoFrameTile height={140 + (index % 3) * 24}>
|
||||
<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' }}
|
||||
/>
|
||||
</YStack>
|
||||
</PhotoFrameTile>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</YStack>
|
||||
<YStack flex={1} gap="$3">
|
||||
{(loading || rightColumn.length === 0 ? Array.from({ length: 5 }, (_, index) => index) : rightColumn).map(
|
||||
(tile, index) => {
|
||||
if (typeof tile === 'number') {
|
||||
return <PhotoFrameTile key={`right-${tile}`} height={120 + (index % 3) * 28} shimmer shimmerDelayMs={260 + index * 140} />;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={tile.id}
|
||||
unstyled
|
||||
onPress={() => openLightbox(tile.id)}
|
||||
>
|
||||
<PhotoFrameTile height={120 + (index % 3) * 28}>
|
||||
<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' }}
|
||||
/>
|
||||
</YStack>
|
||||
</PhotoFrameTile>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</YStack>
|
||||
</XStack>
|
||||
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
gap="$1"
|
||||
style={{
|
||||
boxShadow: cardShadow,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user