diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index b27f327e..e3108189 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -2854,6 +2854,7 @@ class EventPublicController extends BaseController $since = $request->query('since'); $query = DB::table('photos') ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') + ->leftJoin('emotions', 'photos.emotion_id', '=', 'emotions.id') ->select([ 'photos.id', 'photos.file_path', @@ -2865,6 +2866,10 @@ class EventPublicController extends BaseController 'photos.created_at', 'photos.ingest_source', 'tasks.title as task_title', + 'emotions.name as emotion_name', + 'emotions.icon as emotion_icon', + 'emotions.color as emotion_color', + 'emotions.id as emotion_lookup_id', ]) ->where('photos.event_id', $eventId) ->where('photos.status', 'approved') @@ -2892,6 +2897,20 @@ class EventPublicController extends BaseController $r->task_title = $this->firstLocalizedValue($r->task_title, $fallbacks, 'Unbenannte Aufgabe'); } + $emotion = null; + if ($r->emotion_id) { + $emotionName = $this->firstLocalizedValue($r->emotion_name, $fallbacks, ''); + if ($emotionName !== '') { + $emotion = [ + 'id' => (int) ($r->emotion_lookup_id ?? $r->emotion_id), + 'name' => $emotionName, + 'icon' => $r->emotion_icon ?: null, + 'color' => $r->emotion_color ?: null, + ]; + } + } + $r->emotion = $emotion; + $r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN; return $r; diff --git a/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx b/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx index 292be05e..6f6dd897 100644 --- a/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx @@ -101,6 +101,7 @@ vi.mock('lucide-react', () => ({ ChevronLeft: () => left, ChevronRight: () => right, Loader2: () => loader, + Download: () => download, X: () => x, })); diff --git a/resources/js/guest-v2/components/ShareSheet.tsx b/resources/js/guest-v2/components/ShareSheet.tsx index 4f5921f4..e788665f 100644 --- a/resources/js/guest-v2/components/ShareSheet.tsx +++ b/resources/js/guest-v2/components/ShareSheet.tsx @@ -14,6 +14,7 @@ type ShareSheetProps = { eventName?: string | null; url?: string | null; loading?: boolean; + variant?: 'modal' | 'inline'; onShareNative: () => void; onShareWhatsApp: () => void; onShareMessages: () => void; @@ -36,6 +37,7 @@ export default function ShareSheet({ eventName, url, loading = false, + variant = 'modal', onShareNative, onShareWhatsApp, onShareMessages, @@ -45,6 +47,199 @@ export default function ShareSheet({ const { isDark } = useGuestThemeVariant(); const mutedSurface = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'; const mutedBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'; + const [inlineMounted, setInlineMounted] = React.useState(false); + const [inlineVisible, setInlineVisible] = React.useState(false); + + React.useEffect(() => { + if (variant !== 'inline') return; + if (open) { + setInlineMounted(true); + const raf = window.requestAnimationFrame(() => { + setInlineVisible(true); + }); + return () => window.cancelAnimationFrame(raf); + } + setInlineVisible(false); + const timeout = window.setTimeout(() => { + setInlineMounted(false); + }, 220); + return () => window.clearTimeout(timeout); + }, [open, variant]); + + const content = ( + + + + + {t('share.title', 'Shared photo')} + + {photoId ? ( + + #{photoId} + + ) : null} + {eventName ? ( + + {eventName} + + ) : null} + + + + + + + + + + + + {url ? ( + + {url} + + ) : null} + + ); + + if (variant === 'inline') { + if (!inlineMounted) { + return null; + } + + return ( + + - - - - - - - - - - {url ? ( - - {url} - - ) : null} - + {content} ); diff --git a/resources/js/guest-v2/screens/GalleryScreen.tsx b/resources/js/guest-v2/screens/GalleryScreen.tsx index ebff547d..86e5a014 100644 --- a/resources/js/guest-v2/screens/GalleryScreen.tsx +++ b/resources/js/guest-v2/screens/GalleryScreen.tsx @@ -2,7 +2,7 @@ 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, Heart, Loader2, Share2, Sparkles, X } from 'lucide-react'; +import { Camera, ChevronLeft, ChevronRight, Download, Heart, Loader2, Share2, Sparkles, X } from 'lucide-react'; import AppShell from '../components/AppShell'; import PhotoFrameTile from '../components/PhotoFrameTile'; import ShareSheet from '../components/ShareSheet'; @@ -27,12 +27,26 @@ type GalleryTile = { createdAt?: string | null; ingestSource?: string | null; sessionId?: string | null; + 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; + taskId?: number | null; + taskLabel?: string | null; + emotion?: { + name?: string | null; + icon?: string | null; + color?: string | null; + } | null; }; function normalizeImageUrl(src?: string | null) { @@ -87,6 +101,16 @@ export default function GalleryScreen() { const pendingNotFoundRef = React.useRef(false); const photosRef = React.useRef([]); const galleryLoadingRef = React.useRef(Boolean(token)); + const transitionDirectionRef = React.useRef<'next' | 'prev'>('next'); + const lastLightboxPhotoRef = React.useRef(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) { @@ -116,6 +140,44 @@ export default function GalleryScreen() { ?? (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 | null) ?? null; + 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, @@ -123,6 +185,9 @@ 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, + taskId, + taskLabel, + emotion, }; }) .filter((item) => item.id && item.imageUrl); @@ -310,7 +375,14 @@ export default function GalleryScreen() { } const seed = lightboxSelected - ? { id: lightboxSelected.id, imageUrl: lightboxSelected.imageUrl, likes: lightboxSelected.likes } + ? { + id: lightboxSelected.id, + imageUrl: lightboxSelected.imageUrl, + likes: lightboxSelected.likes, + taskId: lightboxSelected.taskId ?? null, + taskLabel: lightboxSelected.taskLabel ?? null, + emotion: lightboxSelected.emotion ?? null, + } : null; if (seed) { setLightboxPhoto(seed); @@ -341,7 +413,7 @@ export default function GalleryScreen() { const photo = await fetchPhoto(selectedPhotoId, locale); if (!active) return; if (photo) { - const mapped = mapFullPhoto(photo as Record); + const mapped = mapFullPhoto(photo as Record); if (mapped) { setLightboxPhoto(mapped); setLikesById((prev) => ({ ...prev, [mapped.id]: mapped.likes })); @@ -373,6 +445,36 @@ export default function GalleryScreen() { }; }, [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 = ''; @@ -391,6 +493,22 @@ export default function GalleryScreen() { }; }, [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; @@ -419,6 +537,7 @@ export default function GalleryScreen() { if (lightboxIndex <= 0) return; const prevId = displayPhotos[lightboxIndex - 1]?.id; if (prevId) { + transitionDirectionRef.current = 'prev'; openLightbox(prevId); } }, [displayPhotos, lightboxIndex, openLightbox]); @@ -427,6 +546,7 @@ export default function GalleryScreen() { if (lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1) return; const nextId = displayPhotos[lightboxIndex + 1]?.id; if (nextId) { + transitionDirectionRef.current = 'next'; openLightbox(nextId); } }, [displayPhotos, lightboxIndex, openLightbox]); @@ -539,6 +659,17 @@ export default function GalleryScreen() { [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; }; @@ -558,9 +689,11 @@ export default function GalleryScreen() { return; } if (delta > 0) { + transitionDirectionRef.current = 'prev'; goPrev(); return; } + transitionDirectionRef.current = 'next'; goNext(); }; @@ -828,7 +961,7 @@ export default function GalleryScreen() { )} - {lightboxOpen ? ( + {lightboxOpen || lightboxMounted ? ( - + {lightboxPhoto ? ( @@ -907,45 +1012,72 @@ export default function GalleryScreen() { onTouchEnd={handleTouchEnd} style={{ zIndex: 0 }} > - {t('galleryPage.photo.alt', - + {(() => { + 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 ( + {t('galleryPage.photo.alt', + ); + } + + return ( + <> + {from ? ( + {t('galleryPage.photo.alt', + ) : null} + {t('galleryPage.photo.alt', + + ); + })()} ) : ( @@ -965,16 +1097,91 @@ export default function GalleryScreen() { )} - - {lightboxIndex >= 0 ? `${lightboxIndex + 1} / ${displayPhotos.length}` : ''} - + + + {t('galleryPage.hero.label', 'Live-Galerie')} + + {lightboxPhoto?.taskId || lightboxPhoto?.taskLabel ? ( + + {lightboxPhoto.emotion?.icon ? ( + {lightboxPhoto.emotion.icon} + ) : null} + + {lightboxPhoto.taskLabel ?? t('tasks.page.title', 'Task')} + + + ) : ( + + {event?.name ?? t('galleryPage.hero.eventFallback', 'Euer Event')} + + )} + + + + + + + {lightboxIndex >= 0 ? `${lightboxIndex + 1} / ${displayPhotos.length}` : ''} + + {lightboxPhoto ? ( + + + + {likesById[lightboxPhoto.id] ?? lightboxPhoto.likes} + + + ) : null} + + {lightboxPhoto ? ( + + ) : null} + {lightboxPhoto ? ( + + ) : null} + + { + 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" + /> - - - {lightboxPhoto - ? t( - 'galleryPage.lightbox.likes', - { count: likesById[lightboxPhoto.id] ?? lightboxPhoto.likes }, - '{count} likes' - ) - : ''} - - - - - ) : null} - { - 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)} - /> ); } @@ -1083,9 +1281,50 @@ function mapFullPhoto(photo: Record): LightboxPhoto | null { ?? (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 | 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; return { id, imageUrl, likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0, + taskId, + taskLabel, + emotion, }; } diff --git a/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx b/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx index dce50e16..8ffe721d 100644 --- a/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx +++ b/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx @@ -3,7 +3,7 @@ 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 { ArrowLeft, ChevronLeft, ChevronRight, Download, Heart, Share2 } from 'lucide-react'; import { useGesture } from '@use-gesture/react'; import { animated, to, useSpring } from '@react-spring/web'; import AppShell from '../components/AppShell'; @@ -370,6 +370,17 @@ export default function PhotoLightboxScreen() { [closeShareSheet, copyLink, shareText, shareTitle] ); + const downloadPhoto = React.useCallback((url?: string | null, id?: number | null) => { + if (!url || !id) return; + const link = document.createElement('a'); + link.href = url; + link.download = `photo-${id}.jpg`; + link.rel = 'noreferrer'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, []); + const bind = useGesture( { onDrag: ({ down, movement: [mx, my], offset: [ox, oy], last, event }) => { @@ -449,7 +460,7 @@ export default function PhotoLightboxScreen() { return ( - +