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 (
+
+
+ );
+ }
return (
-
-
-
-
- {t('share.title', 'Shared photo')}
-
- {photoId ? (
-
- #{photoId}
-
- ) : null}
- {eventName ? (
-
- {eventName}
-
- ) : null}
-
-
-
-
-
-
-
-
-
-
-
- {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 ? (
-
-
-
-
- {t('galleryPage.hero.label', 'Live-Galerie')}
-
-
- {event?.name ?? t('galleryPage.hero.eventFallback', 'Euer Event')}
-
-
-
-
+
{lightboxPhoto ? (
@@ -907,45 +1012,72 @@ export default function GalleryScreen() {
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 (
+
+ );
+ }
+
+ return (
+ <>
+ {from ? (
+
+ ) : null}
+
+ >
+ );
+ })()}
) : (
@@ -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 (
-
+
- {
- if (!open) {
- closeShareSheet();
- }
- }}
- photoId={selected?.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)}
- />
);
}
diff --git a/tests/Feature/Api/Event/EventPhotosLocaleTest.php b/tests/Feature/Api/Event/EventPhotosLocaleTest.php
index eedd9fb8..a8f0ae83 100644
--- a/tests/Feature/Api/Event/EventPhotosLocaleTest.php
+++ b/tests/Feature/Api/Event/EventPhotosLocaleTest.php
@@ -29,6 +29,8 @@ class EventPhotosLocaleTest extends TestCase
$emotion = Emotion::factory()->create([
'name' => ['de' => 'Freude', 'en' => 'Joy'],
+ 'icon' => '🙂',
+ 'color' => '#FF00AA',
]);
$task = Task::factory()->create([
@@ -52,6 +54,9 @@ class EventPhotosLocaleTest extends TestCase
$responseEn->assertOk();
$responseEn->assertHeader('X-Content-Locale', 'en');
$responseEn->assertJsonFragment(['task_title' => 'Kiss Moment']);
+ $responseEn->assertJsonPath('data.0.emotion.name', 'Joy');
+ $responseEn->assertJsonPath('data.0.emotion.icon', '🙂');
+ $responseEn->assertJsonPath('data.0.emotion.color', '#FF00AA');
$etag = $responseEn->headers->get('ETag');
$this->assertNotEmpty($etag);