1491 lines
57 KiB
TypeScript
1491 lines
57 KiB
TypeScript
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, 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, deletePhoto, fetchGallery, fetchPhoto, likePhoto, unlikePhoto } from '../services/photosApi';
|
|
import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta';
|
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
|
import { useLocale } from '@/guest/i18n/LocaleContext';
|
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
import { buildEventPath } from '../lib/routes';
|
|
import { getBentoSurfaceTokens } from '../lib/bento';
|
|
import { usePollStats } from '../hooks/usePollStats';
|
|
import { pushGuestToast } from '../lib/toast';
|
|
|
|
type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
|
|
|
|
type GalleryTile = {
|
|
id: number;
|
|
imageUrl: string;
|
|
likes: number;
|
|
createdAt?: string | null;
|
|
ingestSource?: string | null;
|
|
sessionId?: string | null;
|
|
isMine?: boolean;
|
|
taskId?: number | null;
|
|
taskLabel?: string | null;
|
|
emotion?: {
|
|
name?: string | null;
|
|
icon?: string | null;
|
|
color?: string | null;
|
|
} | null;
|
|
};
|
|
|
|
type LightboxPhoto = {
|
|
id: number;
|
|
imageUrl: string;
|
|
likes: number;
|
|
isMine?: boolean;
|
|
taskId?: number | null;
|
|
taskLabel?: string | null;
|
|
emotion?: {
|
|
name?: string | null;
|
|
icon?: string | null;
|
|
color?: string | null;
|
|
} | 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 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();
|
|
const { locale } = useLocale();
|
|
const navigate = useNavigate();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const { isDark } = useGuestThemeVariant();
|
|
const bentoSurface = getBentoSurfaceTokens(isDark);
|
|
const cardShadow = bentoSurface.shadow;
|
|
const hardShadow = isDark
|
|
? '0 18px 0 rgba(2, 6, 23, 0.55), 0 32px 40px rgba(2, 6, 23, 0.55)'
|
|
: '0 18px 0 rgba(15, 23, 42, 0.22), 0 30px 36px rgba(15, 23, 42, 0.2)';
|
|
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 { stats } = usePollStats(token ?? null, 12000);
|
|
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
|
|
const uploadPath = React.useMemo(() => buildEventPath(token ?? null, '/upload'), [token]);
|
|
const numberFormatter = React.useMemo(() => new Intl.NumberFormat(locale), [locale]);
|
|
const [lightboxPhoto, setLightboxPhoto] = React.useState<LightboxPhoto | null>(null);
|
|
const [lightboxLoading, setLightboxLoading] = React.useState(false);
|
|
const [lightboxError, setLightboxError] = React.useState<'notFound' | 'loadFailed' | null>(null);
|
|
const [likesById, setLikesById] = React.useState<Record<number, number>>({});
|
|
const [shareSheet, setShareSheet] = React.useState<{ url: string | null; loading: boolean }>({
|
|
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);
|
|
const pendingNotFoundRef = React.useRef(false);
|
|
const photosRef = React.useRef<GalleryTile[]>([]);
|
|
const galleryLoadingRef = React.useRef(Boolean(token));
|
|
const transitionDirectionRef = React.useRef<'next' | 'prev'>('next');
|
|
const lastLightboxPhotoRef = React.useRef<LightboxPhoto | null>(null);
|
|
const [lightboxTransition, setLightboxTransition] = React.useState<{
|
|
from: LightboxPhoto | null;
|
|
to: LightboxPhoto | null;
|
|
direction: 'next' | 'prev';
|
|
active: boolean;
|
|
}>({ from: null, to: null, direction: 'next', active: false });
|
|
const [lightboxMounted, setLightboxMounted] = React.useState(false);
|
|
const [lightboxVisible, setLightboxVisible] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
if (!token) {
|
|
setPhotos([]);
|
|
galleryLoadingRef.current = false;
|
|
return;
|
|
}
|
|
|
|
let active = true;
|
|
setLoading(true);
|
|
galleryLoadingRef.current = 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)
|
|
);
|
|
const rawTaskId = Number(record.task_id ?? record.taskId ?? 0);
|
|
const taskId = Number.isFinite(rawTaskId) && rawTaskId > 0 ? rawTaskId : null;
|
|
const taskLabel =
|
|
typeof record.task_title === 'string'
|
|
? record.task_title
|
|
: typeof record.task_name === 'string'
|
|
? record.task_name
|
|
: typeof record.task === 'string'
|
|
? record.task
|
|
: typeof record.task_label === 'string'
|
|
? 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
|
|
: typeof record.emotion_name === 'string'
|
|
? record.emotion_name
|
|
: null;
|
|
const emotionIcon =
|
|
typeof rawEmotion?.icon === 'string'
|
|
? rawEmotion.icon
|
|
: typeof rawEmotion?.emoji === 'string'
|
|
? rawEmotion.emoji
|
|
: typeof record.emotion_icon === 'string'
|
|
? record.emotion_icon
|
|
: typeof record.emotion_emoji === 'string'
|
|
? record.emotion_emoji
|
|
: null;
|
|
const emotionColor =
|
|
typeof rawEmotion?.color === 'string'
|
|
? rawEmotion.color
|
|
: typeof record.emotion_color === 'string'
|
|
? record.emotion_color
|
|
: null;
|
|
const emotion = emotionName || emotionIcon || emotionColor
|
|
? { name: emotionName, icon: emotionIcon, color: emotionColor }
|
|
: 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,
|
|
isMine,
|
|
taskId,
|
|
taskLabel,
|
|
emotion,
|
|
};
|
|
})
|
|
.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);
|
|
}
|
|
galleryLoadingRef.current = false;
|
|
});
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [token, locale]);
|
|
|
|
React.useEffect(() => {
|
|
photosRef.current = photos;
|
|
}, [photos]);
|
|
|
|
const [myPhotoIds, setMyPhotoIds] = React.useState<Set<number>>(() => readMyPhotoIds());
|
|
|
|
React.useEffect(() => {
|
|
setMyPhotoIds(readMyPhotoIds());
|
|
}, [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) => 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());
|
|
} 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);
|
|
const isEmpty = !loading && displayPhotos.length === 0;
|
|
const isSingle = !loading && displayPhotos.length === 1;
|
|
const selectedPhotoId = Number(searchParams.get('photo') ?? 0);
|
|
const lightboxIndex = React.useMemo(() => {
|
|
if (!selectedPhotoId) {
|
|
return -1;
|
|
}
|
|
return displayPhotos.findIndex((item) => item.id === selectedPhotoId);
|
|
}, [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')) {
|
|
setFilter('latest');
|
|
}
|
|
}, [filter, photos]);
|
|
|
|
React.useEffect(() => {
|
|
fallbackAttemptedRef.current = false;
|
|
}, [selectedPhotoId]);
|
|
|
|
React.useEffect(() => {
|
|
if (!lightboxOpen || !selectedPhotoId) {
|
|
return;
|
|
}
|
|
if (lightboxIndex >= 0) {
|
|
return;
|
|
}
|
|
if (filter !== 'latest' && !fallbackAttemptedRef.current) {
|
|
fallbackAttemptedRef.current = true;
|
|
setFilter('latest');
|
|
}
|
|
}, [filter, lightboxIndex, lightboxOpen, selectedPhotoId]);
|
|
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;
|
|
const next = new URLSearchParams(searchParams);
|
|
next.set('photo', String(photoId));
|
|
setSearchParams(next, { replace: false });
|
|
},
|
|
[searchParams, setSearchParams, token]
|
|
);
|
|
const closeLightbox = React.useCallback(() => {
|
|
const next = new URLSearchParams(searchParams);
|
|
next.delete('photo');
|
|
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;
|
|
}
|
|
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;
|
|
}
|
|
const rawIsMine = record.is_mine ?? record.isMine;
|
|
const isMine = rawIsMine === true || rawIsMine === 1 || rawIsMine === '1';
|
|
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,
|
|
isMine,
|
|
} satisfies GalleryTile;
|
|
})
|
|
.filter(Boolean) as GalleryTile[];
|
|
if (mapped.length === 0) {
|
|
return prev;
|
|
}
|
|
return [...mapped, ...prev];
|
|
});
|
|
}, [delta.photos]);
|
|
|
|
const heroStatsLine = t(
|
|
'galleryPage.hero.stats',
|
|
{
|
|
photoCount: numberFormatter.format(photos.length),
|
|
likeCount: numberFormatter.format(stats.likesCount ?? 0),
|
|
guestCount: numberFormatter.format(stats.onlineGuests || stats.guestCount || 0),
|
|
},
|
|
`${numberFormatter.format(photos.length)} Fotos · ${numberFormatter.format(stats.likesCount ?? 0)} ❤️ · ${numberFormatter.format(stats.onlineGuests || stats.guestCount || 0)} Gäste online`
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
setLikesById((prev) => {
|
|
const next = { ...prev };
|
|
for (const photo of photos) {
|
|
if (next[photo.id] === undefined) {
|
|
next[photo.id] = photo.likes;
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
}, [photos]);
|
|
|
|
React.useEffect(() => {
|
|
if (!lightboxOpen) {
|
|
setLightboxPhoto(null);
|
|
setLightboxLoading(false);
|
|
setLightboxError(null);
|
|
pendingNotFoundRef.current = false;
|
|
setDeleteConfirmOpen(false);
|
|
setDeleteBusy(false);
|
|
return;
|
|
}
|
|
|
|
const seed = lightboxSelected
|
|
? {
|
|
id: lightboxSelected.id,
|
|
imageUrl: lightboxSelected.imageUrl,
|
|
likes: lightboxSelected.likes,
|
|
isMine: lightboxSelected.isMine,
|
|
taskId: lightboxSelected.taskId ?? null,
|
|
taskLabel: lightboxSelected.taskLabel ?? null,
|
|
emotion: lightboxSelected.emotion ?? null,
|
|
}
|
|
: null;
|
|
if (seed) {
|
|
setLightboxPhoto(seed);
|
|
}
|
|
const hasSeed = Boolean(seed);
|
|
|
|
if (hasSeed) {
|
|
setLightboxLoading(false);
|
|
return;
|
|
}
|
|
|
|
let active = true;
|
|
const maxRetryMs = 10_000;
|
|
const retryDelayMs = 1500;
|
|
let timedOut = false;
|
|
const timeoutId = window.setTimeout(() => {
|
|
timedOut = true;
|
|
}, maxRetryMs);
|
|
setLightboxLoading(true);
|
|
setLightboxError(null);
|
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
const loadPhoto = async () => {
|
|
let lastError: unknown = null;
|
|
|
|
while (active && !timedOut) {
|
|
try {
|
|
const photo = await fetchPhoto(selectedPhotoId, locale);
|
|
if (!active) return;
|
|
if (photo) {
|
|
const mapped = mapFullPhoto(photo as Record<string, unknown>);
|
|
if (mapped) {
|
|
setLightboxPhoto(mapped);
|
|
setLikesById((prev) => ({ ...prev, [mapped.id]: mapped.likes }));
|
|
setLightboxLoading(false);
|
|
return;
|
|
}
|
|
}
|
|
lastError = { status: 404 };
|
|
} catch (error) {
|
|
console.error('Lightbox photo load failed', error);
|
|
lastError = error;
|
|
}
|
|
|
|
if (!active || timedOut) break;
|
|
await sleep(retryDelayMs);
|
|
}
|
|
|
|
if (!active) return;
|
|
const status = (lastError as { status?: number } | null)?.status;
|
|
setLightboxError(status === 404 ? 'notFound' : 'loadFailed');
|
|
setLightboxLoading(false);
|
|
};
|
|
|
|
void loadPhoto();
|
|
|
|
return () => {
|
|
active = false;
|
|
window.clearTimeout(timeoutId);
|
|
};
|
|
}, [lightboxOpen, lightboxSelected, locale, selectedPhotoId]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
if (!lightboxPhoto) {
|
|
lastLightboxPhotoRef.current = null;
|
|
setLightboxTransition({ from: null, to: null, direction: 'next', active: false });
|
|
return;
|
|
}
|
|
|
|
const previous = lastLightboxPhotoRef.current;
|
|
if (!previous || previous.id === lightboxPhoto.id) {
|
|
lastLightboxPhotoRef.current = lightboxPhoto;
|
|
setLightboxTransition({ from: null, to: lightboxPhoto, direction: transitionDirectionRef.current, active: false });
|
|
return;
|
|
}
|
|
|
|
const direction = transitionDirectionRef.current;
|
|
setLightboxTransition({ from: previous, to: lightboxPhoto, direction, active: false });
|
|
const raf = window.requestAnimationFrame(() => {
|
|
setLightboxTransition((state) => ({ ...state, active: true }));
|
|
});
|
|
const timeout = window.setTimeout(() => {
|
|
setLightboxTransition({ from: null, to: lightboxPhoto, direction, active: false });
|
|
}, 420);
|
|
lastLightboxPhotoRef.current = lightboxPhoto;
|
|
|
|
return () => {
|
|
window.cancelAnimationFrame(raf);
|
|
window.clearTimeout(timeout);
|
|
};
|
|
}, [lightboxPhoto]);
|
|
|
|
React.useEffect(() => {
|
|
if (!lightboxOpen) {
|
|
document.body.style.overflow = '';
|
|
return;
|
|
}
|
|
document.body.style.overflow = 'hidden';
|
|
const handleKey = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
closeLightbox();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKey);
|
|
return () => {
|
|
document.body.style.overflow = '';
|
|
window.removeEventListener('keydown', handleKey);
|
|
};
|
|
}, [lightboxOpen]);
|
|
|
|
React.useEffect(() => {
|
|
if (lightboxOpen) {
|
|
setLightboxMounted(true);
|
|
const raf = window.requestAnimationFrame(() => {
|
|
setLightboxVisible(true);
|
|
});
|
|
return () => window.cancelAnimationFrame(raf);
|
|
}
|
|
|
|
setLightboxVisible(false);
|
|
const timeout = window.setTimeout(() => {
|
|
setLightboxMounted(false);
|
|
}, 240);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [lightboxOpen]);
|
|
|
|
React.useEffect(() => {
|
|
if (!pendingNotFoundRef.current) return;
|
|
if (loading || galleryLoadingRef.current) return;
|
|
if (lightboxSelected || photosRef.current.some((photo) => photo.id === selectedPhotoId)) {
|
|
pendingNotFoundRef.current = false;
|
|
return;
|
|
}
|
|
pendingNotFoundRef.current = false;
|
|
setLightboxError('notFound');
|
|
}, [lightboxSelected, loading]);
|
|
|
|
React.useEffect(() => {
|
|
if (!lightboxOpen || !lightboxError) {
|
|
return;
|
|
}
|
|
pushGuestToast({
|
|
text: lightboxError === 'notFound'
|
|
? t('lightbox.errors.notFound', 'Photo not found')
|
|
: t('lightbox.errors.loadFailed', 'Failed to load photo'),
|
|
type: 'warning',
|
|
});
|
|
closeLightbox();
|
|
}, [closeLightbox, lightboxError, lightboxOpen, t]);
|
|
|
|
const goPrev = React.useCallback(() => {
|
|
if (lightboxIndex <= 0) return;
|
|
const prevId = displayPhotos[lightboxIndex - 1]?.id;
|
|
if (prevId) {
|
|
transitionDirectionRef.current = 'prev';
|
|
openLightbox(prevId);
|
|
}
|
|
}, [displayPhotos, lightboxIndex, openLightbox]);
|
|
|
|
const goNext = React.useCallback(() => {
|
|
if (lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1) return;
|
|
const nextId = displayPhotos[lightboxIndex + 1]?.id;
|
|
if (nextId) {
|
|
transitionDirectionRef.current = 'next';
|
|
openLightbox(nextId);
|
|
}
|
|
}, [displayPhotos, lightboxIndex, openLightbox]);
|
|
|
|
const handleLike = React.useCallback(async () => {
|
|
if (!lightboxPhoto) return;
|
|
const isLiked = likedIds.has(lightboxPhoto.id);
|
|
const current = likesById[lightboxPhoto.id] ?? lightboxPhoto.likes;
|
|
const nextCount = Math.max(0, current + (isLiked ? -1 : 1));
|
|
setLikedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (isLiked) {
|
|
next.delete(lightboxPhoto.id);
|
|
} else {
|
|
next.add(lightboxPhoto.id);
|
|
}
|
|
return next;
|
|
});
|
|
setLikesById((prev) => ({ ...prev, [lightboxPhoto.id]: nextCount }));
|
|
try {
|
|
const count = isLiked ? await unlikePhoto(lightboxPhoto.id) : await likePhoto(lightboxPhoto.id);
|
|
setLikesById((prev) => ({ ...prev, [lightboxPhoto.id]: count }));
|
|
} catch (error) {
|
|
console.error('Like failed', error);
|
|
setLikedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (isLiked) {
|
|
next.add(lightboxPhoto.id);
|
|
} else {
|
|
next.delete(lightboxPhoto.id);
|
|
}
|
|
return next;
|
|
});
|
|
setLikesById((prev) => ({ ...prev, [lightboxPhoto.id]: current }));
|
|
}
|
|
}, [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.');
|
|
|
|
const openShareSheet = React.useCallback(async () => {
|
|
if (!lightboxPhoto || !token) return;
|
|
setShareSheet({ url: null, loading: true });
|
|
try {
|
|
const payload = await createPhotoShareLink(token, lightboxPhoto.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 });
|
|
}
|
|
}, [lightboxPhoto, t, 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((photo?: LightboxPhoto | null) => {
|
|
if (!photo?.imageUrl) return;
|
|
const link = document.createElement('a');
|
|
link.href = photo.imageUrl;
|
|
link.download = `photo-${photo.id}.jpg`;
|
|
link.rel = 'noreferrer';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}, []);
|
|
|
|
const handleTouchStart = (event: React.TouchEvent) => {
|
|
touchStartX.current = event.touches[0]?.clientX ?? null;
|
|
};
|
|
|
|
const handleTouchEnd = (event: React.TouchEvent) => {
|
|
if (touchStartX.current === null) {
|
|
return;
|
|
}
|
|
const endX = event.changedTouches[0]?.clientX ?? null;
|
|
if (endX === null) {
|
|
touchStartX.current = null;
|
|
return;
|
|
}
|
|
const delta = endX - touchStartX.current;
|
|
touchStartX.current = null;
|
|
if (Math.abs(delta) < 60) {
|
|
return;
|
|
}
|
|
if (delta > 0) {
|
|
transitionDirectionRef.current = 'prev';
|
|
goPrev();
|
|
return;
|
|
}
|
|
transitionDirectionRef.current = 'next';
|
|
goNext();
|
|
};
|
|
|
|
return (
|
|
<AppShell>
|
|
<YStack gap="$4">
|
|
<YStack
|
|
padding="$2"
|
|
borderRadius="$bentoLg"
|
|
backgroundColor={bentoSurface.backgroundColor}
|
|
borderWidth={1}
|
|
borderBottomWidth={3}
|
|
borderColor={bentoSurface.borderColor}
|
|
borderBottomColor={bentoSurface.borderBottomColor}
|
|
gap="$2"
|
|
position="relative"
|
|
overflow="hidden"
|
|
style={{
|
|
backgroundImage: isDark
|
|
? 'radial-gradient(120% 120% at 20% 20%, rgba(56, 189, 248, 0.18), transparent 55%), radial-gradient(120% 120% at 80% 15%, rgba(251, 113, 133, 0.18), transparent 60%)'
|
|
: 'radial-gradient(130% 130% at 20% 20%, color-mix(in oklab, var(--guest-primary, #0EA5E9) 25%, white), transparent 55%), radial-gradient(120% 120% at 80% 0%, color-mix(in oklab, var(--guest-secondary, #F43F5E) 18%, white), transparent 60%)',
|
|
boxShadow: hardShadow,
|
|
}}
|
|
>
|
|
<XStack alignItems="center" gap="$2">
|
|
<Sparkles size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}>
|
|
{t('galleryPage.hero.label', 'Live-Galerie')}
|
|
</Text>
|
|
</XStack>
|
|
<XStack alignItems="center" justifyContent="space-between" flexWrap="wrap" gap="$2">
|
|
<Text fontSize="$6" fontFamily="$display" fontWeight="$8">
|
|
{event?.name ?? t('galleryPage.hero.eventFallback', 'Euer Event')}
|
|
</Text>
|
|
<Button
|
|
size="$3"
|
|
backgroundColor="$primary"
|
|
borderRadius="$pill"
|
|
onPress={() => navigate(uploadPath)}
|
|
pressStyle={{ y: 2 }}
|
|
style={{
|
|
boxShadow: isDark ? '0 8px 0 rgba(2, 6, 23, 0.55)' : '0 8px 0 rgba(15, 23, 42, 0.18)',
|
|
}}
|
|
>
|
|
{t('galleryPage.hero.upload', 'Neues Foto hochladen')}
|
|
</Button>
|
|
</XStack>
|
|
{newUploads > 0 ? (
|
|
<YStack
|
|
paddingHorizontal="$2.5"
|
|
paddingVertical="$1"
|
|
borderRadius="$pill"
|
|
backgroundColor={mutedButton}
|
|
borderWidth={1}
|
|
borderColor={mutedButtonBorder}
|
|
alignSelf="flex-start"
|
|
>
|
|
<Text fontSize="$1" fontWeight="$6">
|
|
{t('galleryPage.feed.newUploads', { count: newUploads }, '{count} neue Uploads sind da.')}
|
|
</Text>
|
|
</YStack>
|
|
) : null}
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{heroStatsLine}
|
|
</Text>
|
|
<YStack
|
|
gap="$1"
|
|
paddingTop="$1"
|
|
borderTopWidth={1}
|
|
borderTopColor={bentoSurface.borderColor}
|
|
>
|
|
<XStack gap="$1.5" flexWrap="wrap" justifyContent="center">
|
|
{(
|
|
[
|
|
{ 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="$2"
|
|
backgroundColor={filter === chip.value ? '$primary' : mutedButton}
|
|
borderRadius="$pill"
|
|
borderWidth={1}
|
|
borderColor={filter === chip.value ? '$primary' : mutedButtonBorder}
|
|
onPress={() => setFilter(chip.value)}
|
|
>
|
|
<Text fontSize="$1" fontWeight="$6" color={filter === chip.value ? '#FFFFFF' : undefined}>
|
|
{chip.label}
|
|
</Text>
|
|
</Button>
|
|
))}
|
|
</XStack>
|
|
</YStack>
|
|
</YStack>
|
|
|
|
{isEmpty ? (
|
|
<YStack
|
|
padding="$4"
|
|
borderRadius="$bento"
|
|
backgroundColor={bentoSurface.backgroundColor}
|
|
borderWidth={1}
|
|
borderBottomWidth={3}
|
|
borderColor={bentoSurface.borderColor}
|
|
borderBottomColor={bentoSurface.borderBottomColor}
|
|
gap="$3"
|
|
alignItems="center"
|
|
style={{
|
|
boxShadow: cardShadow,
|
|
}}
|
|
>
|
|
<YStack
|
|
width={64}
|
|
height={64}
|
|
borderRadius={20}
|
|
backgroundColor="$primary"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
style={{ boxShadow: cardShadow }}
|
|
>
|
|
<Camera size={28} color="#FFFFFF" />
|
|
</YStack>
|
|
<Text fontSize="$4" fontWeight="$7" textAlign="center">
|
|
{t('galleryPage.emptyTitle', 'Noch keine Fotos')}
|
|
</Text>
|
|
<Text fontSize="$2" color="$color" opacity={0.7} textAlign="center">
|
|
{t('galleryPage.emptyDescription', 'Lade das erste Foto hoch und starte die Galerie.')}
|
|
</Text>
|
|
<Button
|
|
size="$3"
|
|
backgroundColor="$primary"
|
|
borderRadius="$pill"
|
|
onPress={() => navigate(uploadPath)}
|
|
>
|
|
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
|
|
{t('galleryPage.emptyCta', 'Foto hochladen')}
|
|
</Text>
|
|
</Button>
|
|
</YStack>
|
|
) : isSingle ? (
|
|
<YStack gap="$3">
|
|
<Button unstyled onPress={() => openLightbox(displayPhotos[0].id)}>
|
|
<PhotoFrameTile height={360} borderRadius="$bento">
|
|
<YStack flex={1} width="100%" height="100%" alignItems="center" justifyContent="center">
|
|
<img
|
|
src={displayPhotos[0].imageUrl}
|
|
alt={t('galleryPage.photo.alt', { id: displayPhotos[0].id, suffix: '' }, `Foto ${displayPhotos[0].id}`)}
|
|
loading="eager"
|
|
decoding="async"
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
/>
|
|
</YStack>
|
|
</PhotoFrameTile>
|
|
</Button>
|
|
<Button unstyled onPress={() => navigate(uploadPath)}>
|
|
<PhotoFrameTile height={160} borderRadius="$bento">
|
|
<YStack flex={1} alignItems="center" justifyContent="center" gap="$2" padding="$3">
|
|
<YStack
|
|
width={48}
|
|
height={48}
|
|
borderRadius={16}
|
|
backgroundColor="$primary"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
style={{ boxShadow: cardShadow }}
|
|
>
|
|
<Camera size={20} color="#FFFFFF" />
|
|
</YStack>
|
|
<Text fontSize="$3" fontWeight="$7" textAlign="center">
|
|
{t('galleryPage.promptTitle', 'Füge dein nächstes Foto hinzu')}
|
|
</Text>
|
|
<Text fontSize="$2" color="$color" opacity={0.7} textAlign="center">
|
|
{t('galleryPage.promptDescription', 'Teile den Moment mit allen Gästen.')}
|
|
</Text>
|
|
</YStack>
|
|
</PhotoFrameTile>
|
|
</Button>
|
|
</YStack>
|
|
) : (
|
|
<XStack gap="$3">
|
|
<YStack flex={1} gap="$3">
|
|
{(loading ? 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} />;
|
|
}
|
|
const altText = t('galleryPage.photo.alt', { id: tile.id, suffix: '' }, `Foto ${tile.id}`);
|
|
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={altText}
|
|
loading={index < 4 ? 'eager' : 'lazy'}
|
|
decoding="async"
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
/>
|
|
</YStack>
|
|
</PhotoFrameTile>
|
|
</Button>
|
|
);
|
|
})}
|
|
</YStack>
|
|
<YStack flex={1} gap="$3">
|
|
{(loading ? 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} />;
|
|
}
|
|
const altText = t('galleryPage.photo.alt', { id: tile.id, suffix: '' }, `Foto ${tile.id}`);
|
|
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={altText}
|
|
loading={index < 4 ? 'eager' : 'lazy'}
|
|
decoding="async"
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
/>
|
|
</YStack>
|
|
</PhotoFrameTile>
|
|
</Button>
|
|
);
|
|
})}
|
|
{!loading && rightColumn.length === 0 ? (
|
|
<Button unstyled onPress={() => navigate(uploadPath)}>
|
|
<PhotoFrameTile height={180}>
|
|
<YStack flex={1} alignItems="center" justifyContent="center" gap="$2" padding="$3">
|
|
<YStack
|
|
width={46}
|
|
height={46}
|
|
borderRadius={16}
|
|
backgroundColor="$primary"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
style={{ boxShadow: cardShadow }}
|
|
>
|
|
<Camera size={20} color="#FFFFFF" />
|
|
</YStack>
|
|
<Text fontSize="$3" fontWeight="$7" textAlign="center">
|
|
{t('galleryPage.promptTitle', 'Füge dein nächstes Foto hinzu')}
|
|
</Text>
|
|
<Text fontSize="$2" color="$color" opacity={0.7} textAlign="center">
|
|
{t('galleryPage.promptDescription', 'Teile den Moment mit allen Gästen.')}
|
|
</Text>
|
|
</YStack>
|
|
</PhotoFrameTile>
|
|
</Button>
|
|
) : null}
|
|
</YStack>
|
|
</XStack>
|
|
)}
|
|
|
|
</YStack>
|
|
{lightboxOpen || lightboxMounted ? (
|
|
<YStack
|
|
position="fixed"
|
|
top={0}
|
|
left={0}
|
|
right={0}
|
|
bottom={0}
|
|
zIndex={2000}
|
|
padding="$3"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
style={{
|
|
backgroundColor: isDark
|
|
? `rgba(15, 23, 42, ${lightboxVisible ? 0.75 : 0})`
|
|
: `rgba(15, 23, 42, ${lightboxVisible ? 0.45 : 0})`,
|
|
backdropFilter: lightboxVisible ? 'blur(12px)' : 'blur(0px)',
|
|
transition: 'background-color 220ms ease, backdrop-filter 220ms ease',
|
|
}}
|
|
>
|
|
<Button
|
|
unstyled
|
|
onPress={closeLightbox}
|
|
style={{ position: 'absolute', inset: 0, zIndex: 0 }}
|
|
aria-label={t('common.actions.close', 'Close')}
|
|
/>
|
|
<YStack width="100%" maxWidth={860} gap="$2" position="relative" style={{ zIndex: 1 }}>
|
|
<YStack
|
|
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,
|
|
opacity: lightboxVisible ? 1 : 0,
|
|
transform: lightboxVisible ? 'translateY(0) scale(1)' : 'translateY(12px) scale(0.98)',
|
|
transition: 'transform 240ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms ease',
|
|
}}
|
|
>
|
|
<YStack flex={1} alignItems="center" justifyContent="center" padding="$2">
|
|
{lightboxPhoto ? (
|
|
<YStack
|
|
flex={1}
|
|
width="100%"
|
|
height="100%"
|
|
position="relative"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
onTouchStart={handleTouchStart}
|
|
onTouchEnd={handleTouchEnd}
|
|
style={{ zIndex: 0 }}
|
|
>
|
|
{(() => {
|
|
const { from, to, direction, active } = lightboxTransition;
|
|
const canAnimate = Boolean(from && to && from.id !== to.id);
|
|
const dir = direction === 'next' ? 1 : -1;
|
|
const offset = `${dir * 24}%`;
|
|
const transition = 'transform 420ms cubic-bezier(0.22, 1, 0.36, 1), opacity 420ms ease';
|
|
|
|
if (!canAnimate || !to) {
|
|
return (
|
|
<img
|
|
src={lightboxPhoto.imageUrl}
|
|
alt={t('galleryPage.photo.alt', { id: lightboxPhoto.id, suffix: '' }, `Foto ${lightboxPhoto.id}`)}
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'contain',
|
|
position: 'absolute',
|
|
inset: 0,
|
|
zIndex: 0,
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{from ? (
|
|
<img
|
|
src={from.imageUrl}
|
|
alt={t('galleryPage.photo.alt', { id: from.id, suffix: '' }, `Foto ${from.id}`)}
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'contain',
|
|
position: 'absolute',
|
|
inset: 0,
|
|
zIndex: 0,
|
|
pointerEvents: 'none',
|
|
opacity: active ? 0 : 1,
|
|
transform: active ? `translateX(${dir * -24}%) scale(0.96)` : 'translateX(0) scale(1)',
|
|
transition,
|
|
willChange: 'transform, opacity',
|
|
}}
|
|
/>
|
|
) : null}
|
|
<img
|
|
src={to.imageUrl}
|
|
alt={t('galleryPage.photo.alt', { id: to.id, suffix: '' }, `Foto ${to.id}`)}
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'contain',
|
|
position: 'absolute',
|
|
inset: 0,
|
|
zIndex: 1,
|
|
pointerEvents: 'none',
|
|
opacity: active ? 1 : 0,
|
|
transform: active ? 'translateX(0) scale(1)' : `translateX(${offset}) scale(0.98)`,
|
|
transition,
|
|
willChange: 'transform, opacity',
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
})()}
|
|
</YStack>
|
|
) : (
|
|
<YStack alignItems="center" gap="$2">
|
|
{lightboxLoading ? (
|
|
<>
|
|
<Loader2 size={24} className="animate-spin" color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
|
{t('galleryPage.loading', 'Loading…')}
|
|
</Text>
|
|
</>
|
|
) : (
|
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
|
{t('lightbox.errors.notFound', 'Photo not found')}
|
|
</Text>
|
|
)}
|
|
</YStack>
|
|
)}
|
|
</YStack>
|
|
<XStack
|
|
position="absolute"
|
|
top={0}
|
|
left={0}
|
|
right={0}
|
|
alignItems="center"
|
|
justifyContent="space-between"
|
|
padding="$3"
|
|
borderRadius={0}
|
|
backgroundColor={isDark ? 'rgba(12, 16, 32, 0.72)' : 'rgba(255, 255, 255, 0.9)'}
|
|
borderWidth={1}
|
|
borderColor={mutedButtonBorder}
|
|
style={{ backdropFilter: 'blur(10px)' }}
|
|
>
|
|
<YStack gap="$0.5" flexShrink={1}>
|
|
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.1}>
|
|
{t('galleryPage.hero.label', 'Live-Galerie')}
|
|
</Text>
|
|
{lightboxPhoto?.taskId || lightboxPhoto?.taskLabel ? (
|
|
<XStack
|
|
alignItems="center"
|
|
gap="$1.5"
|
|
paddingHorizontal="$2"
|
|
paddingVertical="$1"
|
|
borderRadius="$pill"
|
|
borderWidth={1}
|
|
borderColor={lightboxPhoto.emotion?.color ?? mutedButtonBorder}
|
|
backgroundColor={isDark ? 'rgba(12, 16, 32, 0.55)' : 'rgba(255, 255, 255, 0.75)'}
|
|
style={{
|
|
backgroundImage: lightboxPhoto.emotion?.color
|
|
? `linear-gradient(135deg, color-mix(in oklab, ${lightboxPhoto.emotion.color} 35%, transparent), color-mix(in oklab, ${lightboxPhoto.emotion.color} 10%, transparent))`
|
|
: undefined,
|
|
}}
|
|
>
|
|
{lightboxPhoto.emotion?.icon ? (
|
|
<Text fontSize="$2">{lightboxPhoto.emotion.icon}</Text>
|
|
) : null}
|
|
<Text fontSize="$2" fontWeight="$6" numberOfLines={1}>
|
|
{lightboxPhoto.taskLabel ?? t('tasks.page.title', 'Task')}
|
|
</Text>
|
|
</XStack>
|
|
) : (
|
|
<Text fontSize="$3" fontWeight="$7" numberOfLines={1}>
|
|
{event?.name ?? t('galleryPage.hero.eventFallback', 'Euer Event')}
|
|
</Text>
|
|
)}
|
|
</YStack>
|
|
<Button
|
|
size="$3"
|
|
circular
|
|
backgroundColor={mutedButton}
|
|
borderWidth={1}
|
|
borderColor={mutedButtonBorder}
|
|
onPress={closeLightbox}
|
|
aria-label={t('common.actions.close', 'Close')}
|
|
>
|
|
<X size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
</Button>
|
|
</XStack>
|
|
<XStack
|
|
position="absolute"
|
|
bottom={0}
|
|
left={0}
|
|
right={0}
|
|
alignItems="center"
|
|
justifyContent="space-between"
|
|
padding="$3"
|
|
borderRadius={0}
|
|
backgroundColor={isDark ? 'rgba(12, 16, 32, 0.7)' : 'rgba(255, 255, 255, 0.85)'}
|
|
borderWidth={1}
|
|
borderColor={mutedButtonBorder}
|
|
style={{ backdropFilter: 'blur(10px)' }}
|
|
>
|
|
<XStack alignItems="center" gap="$2">
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{lightboxIndex >= 0 ? `${lightboxIndex + 1} / ${displayPhotos.length}` : ''}
|
|
</Text>
|
|
{lightboxPhoto ? (
|
|
<XStack alignItems="center" gap="$1">
|
|
<Heart size={12} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{likesById[lightboxPhoto.id] ?? lightboxPhoto.likes}
|
|
</Text>
|
|
</XStack>
|
|
) : null}
|
|
</XStack>
|
|
<XStack
|
|
gap="$1"
|
|
padding="$1"
|
|
borderRadius="$pill"
|
|
backgroundColor={mutedButton}
|
|
borderWidth={1}
|
|
borderColor={mutedButtonBorder}
|
|
alignItems="center"
|
|
>
|
|
{lightboxPhoto ? (
|
|
<Button
|
|
unstyled
|
|
paddingHorizontal="$2.5"
|
|
paddingVertical="$1.5"
|
|
onPress={handleLike}
|
|
aria-label={t('galleryPage.photo.likeAria', 'Like')}
|
|
>
|
|
<Heart size={16} color={likedIds.has(lightboxPhoto.id) ? '#F43F5E' : (isDark ? '#F8FAFF' : '#0F172A')} />
|
|
</Button>
|
|
) : null}
|
|
{lightboxPhoto ? (
|
|
<Button
|
|
unstyled
|
|
paddingHorizontal="$2"
|
|
paddingVertical="$1.5"
|
|
onPress={() => downloadPhoto(lightboxPhoto)}
|
|
aria-label={t('common.actions.download', 'Download')}
|
|
>
|
|
<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>
|
|
<Button
|
|
unstyled
|
|
paddingHorizontal="$2.5"
|
|
paddingVertical="$1.5"
|
|
onPress={() => {
|
|
transitionDirectionRef.current = 'prev';
|
|
goPrev();
|
|
}}
|
|
disabled={lightboxIndex <= 0}
|
|
opacity={lightboxIndex <= 0 ? 0.4 : 1}
|
|
>
|
|
<ChevronLeft size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
</Button>
|
|
<Button
|
|
unstyled
|
|
paddingHorizontal="$2.5"
|
|
paddingVertical="$1.5"
|
|
onPress={() => {
|
|
transitionDirectionRef.current = 'next';
|
|
goNext();
|
|
}}
|
|
disabled={lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1}
|
|
opacity={lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1 ? 0.4 : 1}
|
|
>
|
|
<ChevronRight size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
</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) => {
|
|
if (!open) {
|
|
closeShareSheet();
|
|
}
|
|
}}
|
|
photoId={lightboxPhoto?.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"
|
|
/>
|
|
</YStack>
|
|
</YStack>
|
|
</YStack>
|
|
) : null}
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
function mapFullPhoto(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;
|
|
const taskLabel =
|
|
typeof photo.task_title === 'string'
|
|
? photo.task_title
|
|
: typeof photo.task_name === 'string'
|
|
? photo.task_name
|
|
: typeof photo.task === 'string'
|
|
? photo.task
|
|
: typeof photo.task_label === 'string'
|
|
? photo.task_label
|
|
: null;
|
|
const rawTaskId = Number(photo.task_id ?? photo.taskId ?? 0);
|
|
const taskId = Number.isFinite(rawTaskId) && rawTaskId > 0 ? rawTaskId : null;
|
|
const rawEmotion = (photo.emotion as Record<string, unknown> | null) ?? null;
|
|
const emotionName =
|
|
typeof rawEmotion?.name === 'string'
|
|
? rawEmotion.name
|
|
: typeof photo.emotion_name === 'string'
|
|
? photo.emotion_name
|
|
: null;
|
|
const emotionIcon =
|
|
typeof rawEmotion?.icon === 'string'
|
|
? rawEmotion.icon
|
|
: typeof rawEmotion?.emoji === 'string'
|
|
? rawEmotion.emoji
|
|
: typeof photo.emotion_icon === 'string'
|
|
? photo.emotion_icon
|
|
: typeof photo.emotion_emoji === 'string'
|
|
? photo.emotion_emoji
|
|
: null;
|
|
const emotionColor =
|
|
typeof rawEmotion?.color === 'string'
|
|
? rawEmotion.color
|
|
: typeof photo.emotion_color === 'string'
|
|
? photo.emotion_color
|
|
: 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,
|
|
};
|
|
}
|