|
|
|
|
@@ -2,12 +2,12 @@ import React from 'react';
|
|
|
|
|
import { YStack, XStack } from '@tamagui/stacks';
|
|
|
|
|
import { SizableText as Text } from '@tamagui/text';
|
|
|
|
|
import { Button } from '@tamagui/button';
|
|
|
|
|
import { Camera, ChevronLeft, ChevronRight, Download, Heart, Loader2, Share2, Sparkles, X } from 'lucide-react';
|
|
|
|
|
import { Camera, ChevronLeft, ChevronRight, Download, Heart, Loader2, Share2, Sparkles, Trash2, 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 { createPhotoShareLink, fetchGallery, fetchPhoto, likePhoto, unlikePhoto } from '../services/photosApi';
|
|
|
|
|
import { createPhotoShareLink, deletePhoto, fetchGallery, fetchPhoto, likePhoto, unlikePhoto } from '../services/photosApi';
|
|
|
|
|
import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta';
|
|
|
|
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
|
|
|
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
|
|
|
|
@@ -27,6 +27,7 @@ type GalleryTile = {
|
|
|
|
|
createdAt?: string | null;
|
|
|
|
|
ingestSource?: string | null;
|
|
|
|
|
sessionId?: string | null;
|
|
|
|
|
isMine?: boolean;
|
|
|
|
|
taskId?: number | null;
|
|
|
|
|
taskLabel?: string | null;
|
|
|
|
|
emotion?: {
|
|
|
|
|
@@ -40,6 +41,7 @@ type LightboxPhoto = {
|
|
|
|
|
id: number;
|
|
|
|
|
imageUrl: string;
|
|
|
|
|
likes: number;
|
|
|
|
|
isMine?: boolean;
|
|
|
|
|
taskId?: number | null;
|
|
|
|
|
taskLabel?: string | null;
|
|
|
|
|
emotion?: {
|
|
|
|
|
@@ -66,6 +68,20 @@ function normalizeImageUrl(src?: string | null) {
|
|
|
|
|
return `/${cleanPath}`.replace(/\/+/g, '/');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readMyPhotoIds(): Set<number> {
|
|
|
|
|
try {
|
|
|
|
|
const raw = localStorage.getItem('my-photo-ids');
|
|
|
|
|
const parsed = raw ? JSON.parse(raw) : [];
|
|
|
|
|
if (!Array.isArray(parsed)) {
|
|
|
|
|
return new Set();
|
|
|
|
|
}
|
|
|
|
|
const ids = parsed.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0);
|
|
|
|
|
return new Set(ids);
|
|
|
|
|
} catch {
|
|
|
|
|
return new Set();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function GalleryScreen() {
|
|
|
|
|
const { token, event } = useEventData();
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
@@ -95,6 +111,8 @@ export default function GalleryScreen() {
|
|
|
|
|
url: null,
|
|
|
|
|
loading: false,
|
|
|
|
|
});
|
|
|
|
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
|
|
|
|
|
const [deleteBusy, setDeleteBusy] = React.useState(false);
|
|
|
|
|
const [likedIds, setLikedIds] = React.useState<Set<number>>(new Set());
|
|
|
|
|
const touchStartX = React.useRef<number | null>(null);
|
|
|
|
|
const fallbackAttemptedRef = React.useRef(false);
|
|
|
|
|
@@ -153,6 +171,8 @@ export default function GalleryScreen() {
|
|
|
|
|
? record.task_label
|
|
|
|
|
: null;
|
|
|
|
|
const rawEmotion = (record.emotion as Record<string, unknown> | null) ?? null;
|
|
|
|
|
const rawIsMine = record.is_mine ?? record.isMine;
|
|
|
|
|
const isMine = rawIsMine === true || rawIsMine === 1 || rawIsMine === '1';
|
|
|
|
|
const emotionName =
|
|
|
|
|
typeof rawEmotion?.name === 'string'
|
|
|
|
|
? rawEmotion.name
|
|
|
|
|
@@ -185,6 +205,7 @@ export default function GalleryScreen() {
|
|
|
|
|
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,
|
|
|
|
|
isMine,
|
|
|
|
|
taskId,
|
|
|
|
|
taskLabel,
|
|
|
|
|
emotion,
|
|
|
|
|
@@ -215,13 +236,10 @@ export default function GalleryScreen() {
|
|
|
|
|
photosRef.current = photos;
|
|
|
|
|
}, [photos]);
|
|
|
|
|
|
|
|
|
|
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>();
|
|
|
|
|
}
|
|
|
|
|
const [myPhotoIds, setMyPhotoIds] = React.useState<Set<number>>(() => readMyPhotoIds());
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
setMyPhotoIds(readMyPhotoIds());
|
|
|
|
|
}, [token]);
|
|
|
|
|
|
|
|
|
|
const filteredPhotos = React.useMemo(() => {
|
|
|
|
|
@@ -229,7 +247,7 @@ export default function GalleryScreen() {
|
|
|
|
|
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));
|
|
|
|
|
list = list.filter((photo) => Boolean(photo.isMine) || 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());
|
|
|
|
|
@@ -253,6 +271,7 @@ export default function GalleryScreen() {
|
|
|
|
|
}, [displayPhotos, selectedPhotoId]);
|
|
|
|
|
const lightboxSelected = lightboxIndex >= 0 ? displayPhotos[lightboxIndex] : null;
|
|
|
|
|
const lightboxOpen = Boolean(selectedPhotoId);
|
|
|
|
|
const canDelete = Boolean(lightboxPhoto && (lightboxPhoto.isMine || myPhotoIds.has(lightboxPhoto.id)));
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (filter === 'photobooth' && !photos.some((photo) => photo.ingestSource === 'photobooth')) {
|
|
|
|
|
@@ -304,6 +323,24 @@ export default function GalleryScreen() {
|
|
|
|
|
setSearchParams(next, { replace: true });
|
|
|
|
|
}, [searchParams, setSearchParams]);
|
|
|
|
|
|
|
|
|
|
const removeMyPhotoId = React.useCallback((photoId: number) => {
|
|
|
|
|
setMyPhotoIds((prev) => {
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
next.delete(photoId);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
const raw = localStorage.getItem('my-photo-ids');
|
|
|
|
|
const parsed = raw ? JSON.parse(raw) : [];
|
|
|
|
|
if (Array.isArray(parsed)) {
|
|
|
|
|
const next = parsed.filter((value) => Number(value) !== photoId);
|
|
|
|
|
localStorage.setItem('my-photo-ids', JSON.stringify(next));
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to update my-photo-ids', error);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (delta.photos.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
@@ -326,6 +363,8 @@ export default function GalleryScreen() {
|
|
|
|
|
if (!id || !imageUrl || existing.has(id)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const rawIsMine = record.is_mine ?? record.isMine;
|
|
|
|
|
const isMine = rawIsMine === true || rawIsMine === 1 || rawIsMine === '1';
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
imageUrl,
|
|
|
|
|
@@ -333,6 +372,7 @@ export default function GalleryScreen() {
|
|
|
|
|
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,
|
|
|
|
|
isMine,
|
|
|
|
|
} satisfies GalleryTile;
|
|
|
|
|
})
|
|
|
|
|
.filter(Boolean) as GalleryTile[];
|
|
|
|
|
@@ -371,6 +411,8 @@ export default function GalleryScreen() {
|
|
|
|
|
setLightboxLoading(false);
|
|
|
|
|
setLightboxError(null);
|
|
|
|
|
pendingNotFoundRef.current = false;
|
|
|
|
|
setDeleteConfirmOpen(false);
|
|
|
|
|
setDeleteBusy(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -379,6 +421,7 @@ export default function GalleryScreen() {
|
|
|
|
|
id: lightboxSelected.id,
|
|
|
|
|
imageUrl: lightboxSelected.imageUrl,
|
|
|
|
|
likes: lightboxSelected.likes,
|
|
|
|
|
isMine: lightboxSelected.isMine,
|
|
|
|
|
taskId: lightboxSelected.taskId ?? null,
|
|
|
|
|
taskLabel: lightboxSelected.taskLabel ?? null,
|
|
|
|
|
emotion: lightboxSelected.emotion ?? null,
|
|
|
|
|
@@ -584,6 +627,40 @@ export default function GalleryScreen() {
|
|
|
|
|
}
|
|
|
|
|
}, [lightboxPhoto, likedIds, likesById]);
|
|
|
|
|
|
|
|
|
|
const handleDelete = React.useCallback(async () => {
|
|
|
|
|
if (!lightboxPhoto || !token || deleteBusy) return;
|
|
|
|
|
setDeleteBusy(true);
|
|
|
|
|
try {
|
|
|
|
|
await deletePhoto(token, lightboxPhoto.id);
|
|
|
|
|
setPhotos((prev) => prev.filter((photo) => photo.id !== lightboxPhoto.id));
|
|
|
|
|
setLikesById((prev) => {
|
|
|
|
|
const next = { ...prev };
|
|
|
|
|
delete next[lightboxPhoto.id];
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
setLikedIds((prev) => {
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
next.delete(lightboxPhoto.id);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
removeMyPhotoId(lightboxPhoto.id);
|
|
|
|
|
setDeleteConfirmOpen(false);
|
|
|
|
|
setDeleteBusy(false);
|
|
|
|
|
closeLightbox();
|
|
|
|
|
pushGuestToast({
|
|
|
|
|
text: t('galleryPage.lightbox.deletedToast', 'Photo deleted.'),
|
|
|
|
|
type: 'success',
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to delete photo', error);
|
|
|
|
|
setDeleteBusy(false);
|
|
|
|
|
pushGuestToast({
|
|
|
|
|
text: t('galleryPage.lightbox.deleteFailed', 'Photo could not be deleted.'),
|
|
|
|
|
type: 'error',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}, [closeLightbox, deleteBusy, lightboxPhoto, removeMyPhotoId, t, token]);
|
|
|
|
|
|
|
|
|
|
const shareTitle = event?.name ?? t('share.title', 'Shared photo');
|
|
|
|
|
const shareText = t('share.shareText', 'Check out this moment on Fotospiel.');
|
|
|
|
|
|
|
|
|
|
@@ -991,6 +1068,7 @@ export default function GalleryScreen() {
|
|
|
|
|
borderRadius="$bentoLg"
|
|
|
|
|
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(255, 255, 255, 0.65)'}
|
|
|
|
|
overflow="hidden"
|
|
|
|
|
position="relative"
|
|
|
|
|
style={{
|
|
|
|
|
height: 'min(76vh, 560px)',
|
|
|
|
|
boxShadow: hardShadow,
|
|
|
|
|
@@ -1213,6 +1291,18 @@ export default function GalleryScreen() {
|
|
|
|
|
<Download size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
{lightboxPhoto && canDelete ? (
|
|
|
|
|
<Button
|
|
|
|
|
unstyled
|
|
|
|
|
paddingHorizontal="$2"
|
|
|
|
|
paddingVertical="$1.5"
|
|
|
|
|
onPress={() => setDeleteConfirmOpen(true)}
|
|
|
|
|
disabled={deleteBusy}
|
|
|
|
|
aria-label={t('galleryPage.lightbox.deleteAria', 'Delete photo')}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
<Button unstyled paddingHorizontal="$2" paddingVertical="$1.5" onPress={openShareSheet}>
|
|
|
|
|
<Share2 size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
|
|
|
</Button>
|
|
|
|
|
@@ -1244,6 +1334,73 @@ export default function GalleryScreen() {
|
|
|
|
|
</Button>
|
|
|
|
|
</XStack>
|
|
|
|
|
</XStack>
|
|
|
|
|
{deleteConfirmOpen ? (
|
|
|
|
|
<YStack
|
|
|
|
|
position="absolute"
|
|
|
|
|
top={0}
|
|
|
|
|
left={0}
|
|
|
|
|
right={0}
|
|
|
|
|
bottom={0}
|
|
|
|
|
alignItems="center"
|
|
|
|
|
justifyContent="center"
|
|
|
|
|
padding="$4"
|
|
|
|
|
backgroundColor={isDark ? 'rgba(2, 6, 23, 0.7)' : 'rgba(15, 23, 42, 0.4)'}
|
|
|
|
|
style={{ backdropFilter: 'blur(6px)', zIndex: 6 }}
|
|
|
|
|
>
|
|
|
|
|
<YStack
|
|
|
|
|
width="100%"
|
|
|
|
|
maxWidth={360}
|
|
|
|
|
padding="$4"
|
|
|
|
|
gap="$3"
|
|
|
|
|
borderRadius="$card"
|
|
|
|
|
backgroundColor="$surface"
|
|
|
|
|
borderWidth={1}
|
|
|
|
|
borderColor={mutedButtonBorder}
|
|
|
|
|
style={{ boxShadow: cardShadow }}
|
|
|
|
|
>
|
|
|
|
|
<YStack gap="$1">
|
|
|
|
|
<Text fontSize="$4" fontWeight="$7">
|
|
|
|
|
{t('galleryPage.lightbox.deleteTitle', 'Foto löschen?')}
|
|
|
|
|
</Text>
|
|
|
|
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
|
|
|
|
{t(
|
|
|
|
|
'galleryPage.lightbox.deleteDescription',
|
|
|
|
|
'Das Foto wird aus der Galerie entfernt und kann nicht wiederhergestellt werden.'
|
|
|
|
|
)}
|
|
|
|
|
</Text>
|
|
|
|
|
</YStack>
|
|
|
|
|
<XStack gap="$2" justifyContent="flex-end">
|
|
|
|
|
<Button
|
|
|
|
|
backgroundColor={mutedButton}
|
|
|
|
|
borderWidth={1}
|
|
|
|
|
borderColor={mutedButtonBorder}
|
|
|
|
|
onPress={() => setDeleteConfirmOpen(false)}
|
|
|
|
|
disabled={deleteBusy}
|
|
|
|
|
>
|
|
|
|
|
<Text fontSize="$2" fontWeight="$6">
|
|
|
|
|
{t('common.actions.cancel', 'Cancel')}
|
|
|
|
|
</Text>
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
backgroundColor="#EF4444"
|
|
|
|
|
borderWidth={1}
|
|
|
|
|
borderColor="#EF4444"
|
|
|
|
|
onPress={handleDelete}
|
|
|
|
|
disabled={deleteBusy}
|
|
|
|
|
>
|
|
|
|
|
<XStack alignItems="center" gap="$1.5">
|
|
|
|
|
{deleteBusy ? <Loader2 size={14} className="animate-spin" color="#FFFFFF" /> : null}
|
|
|
|
|
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
|
|
|
|
|
{deleteBusy
|
|
|
|
|
? t('galleryPage.lightbox.deleting', 'Wird gelöscht…')
|
|
|
|
|
: t('galleryPage.lightbox.deleteConfirm', 'Foto löschen')}
|
|
|
|
|
</Text>
|
|
|
|
|
</XStack>
|
|
|
|
|
</Button>
|
|
|
|
|
</XStack>
|
|
|
|
|
</YStack>
|
|
|
|
|
</YStack>
|
|
|
|
|
) : null}
|
|
|
|
|
<ShareSheet
|
|
|
|
|
open={shareSheet.loading || Boolean(shareSheet.url)}
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
@@ -1319,10 +1476,13 @@ function mapFullPhoto(photo: Record<string, unknown>): LightboxPhoto | null {
|
|
|
|
|
const emotion = emotionName || emotionIcon || emotionColor
|
|
|
|
|
? { name: emotionName, icon: emotionIcon, color: emotionColor }
|
|
|
|
|
: null;
|
|
|
|
|
const rawIsMine = photo.is_mine ?? photo.isMine;
|
|
|
|
|
const isMine = rawIsMine === true || rawIsMine === 1 || rawIsMine === '1';
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
imageUrl,
|
|
|
|
|
likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0,
|
|
|
|
|
isMine,
|
|
|
|
|
taskId,
|
|
|
|
|
taskLabel,
|
|
|
|
|
emotion,
|
|
|
|
|
|