Add emotion data and lightbox share/download
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-05 20:35:11 +01:00
parent ba56cb4e61
commit c6aaf859f5
6 changed files with 657 additions and 295 deletions

View File

@@ -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;

View File

@@ -101,6 +101,7 @@ vi.mock('lucide-react', () => ({
ChevronLeft: () => <span>left</span>,
ChevronRight: () => <span>right</span>,
Loader2: () => <span>loader</span>,
Download: () => <span>download</span>,
X: () => <span>x</span>,
}));

View File

@@ -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 = (
<YStack gap="$3">
<XStack alignItems="center" justifyContent="space-between">
<YStack gap="$1">
<Text fontSize="$2" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}>
{t('share.title', 'Shared photo')}
</Text>
{photoId ? (
<Text fontSize="$5" fontWeight="$8">
#{photoId}
</Text>
) : null}
{eventName ? (
<Text fontSize="$2" color="$color" opacity={0.7}>
{eventName}
</Text>
) : null}
</YStack>
<Button
size="$3"
circular
backgroundColor={mutedSurface}
borderWidth={1}
borderColor={mutedBorder}
onPress={() => onOpenChange(false)}
aria-label={t('common.actions.close', 'Close')}
>
<X size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
<XStack gap="$2" flexWrap="wrap">
<Button
flex={1}
minWidth="45%"
borderRadius="$card"
backgroundColor={mutedSurface}
borderWidth={1}
borderColor={mutedBorder}
onPress={onShareNative}
disabled={loading}
>
<XStack alignItems="center" gap="$2">
<Share2 size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<YStack>
<Text fontSize="$2" fontWeight="$7">
{t('share.button', 'Share')}
</Text>
<Text fontSize="$1" color="$color" opacity={0.7}>
{t('share.title', 'Shared photo')}
</Text>
</YStack>
</XStack>
</Button>
<Button
flex={1}
minWidth="45%"
borderRadius="$card"
backgroundColor="#22C55E"
onPress={onShareWhatsApp}
disabled={loading}
>
<XStack alignItems="center" gap="$2">
<WhatsAppIcon width={18} height={18} />
<YStack>
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
{t('share.whatsapp', 'WhatsApp')}
</Text>
<Text fontSize="$1" color="rgba(255,255,255,0.8)">
{loading ? '...' : ''}
</Text>
</YStack>
</XStack>
</Button>
<Button
flex={1}
minWidth="45%"
borderRadius="$card"
backgroundColor="#38BDF8"
onPress={onShareMessages}
disabled={loading}
>
<XStack alignItems="center" gap="$2">
<MessageSquare size={16} color="#FFFFFF" />
<YStack>
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
{t('share.imessage', 'Messages')}
</Text>
<Text fontSize="$1" color="rgba(255,255,255,0.8)">
{loading ? '...' : ''}
</Text>
</YStack>
</XStack>
</Button>
<Button
flex={1}
minWidth="45%"
borderRadius="$card"
backgroundColor={mutedSurface}
borderWidth={1}
borderColor={mutedBorder}
onPress={onCopyLink}
disabled={loading}
>
<XStack alignItems="center" gap="$2">
<Copy size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<YStack>
<Text fontSize="$2" fontWeight="$7">
{t('share.copyLink', 'Copy link')}
</Text>
<Text fontSize="$1" color="$color" opacity={0.7}>
{loading ? t('share.loading', 'Loading...') : ''}
</Text>
</YStack>
</XStack>
</Button>
</XStack>
{url ? (
<Text fontSize="$1" color="$color" opacity={0.7} numberOfLines={1}>
{url}
</Text>
) : null}
</YStack>
);
if (variant === 'inline') {
if (!inlineMounted) {
return null;
}
return (
<YStack
position="absolute"
inset={0}
zIndex={20}
justifyContent="flex-end"
pointerEvents={open ? 'auto' : 'none'}
>
<Button
unstyled
onPress={() => onOpenChange(false)}
style={{
position: 'absolute',
inset: 0,
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.5)' : 'rgba(15, 23, 42, 0.35)',
opacity: inlineVisible ? 1 : 0,
transition: 'opacity 200ms ease',
}}
aria-label={t('common.actions.close', 'Close')}
/>
<YStack
padding="$4"
backgroundColor="$surface"
borderTopLeftRadius="$6"
borderTopRightRadius="$6"
style={{
transform: inlineVisible ? 'translateY(0)' : 'translateY(100%)',
transition: 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1)',
}}
>
<YStack
height={5}
width={52}
backgroundColor="#CBD5E1"
borderRadius={999}
alignSelf="center"
marginBottom="$3"
/>
{content}
</YStack>
</YStack>
);
}
return (
<Sheet
@@ -57,129 +252,7 @@ export default function ShareSheet({
<Sheet.Overlay {...({ backgroundColor: isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(15, 23, 42, 0.3)' } as any)} />
<Sheet.Frame padding="$4" backgroundColor="$surface" borderTopLeftRadius="$6" borderTopRightRadius="$6">
<Sheet.Handle height={5} width={52} backgroundColor="#CBD5E1" borderRadius={999} marginBottom="$3" />
<YStack gap="$3">
<XStack alignItems="center" justifyContent="space-between">
<YStack gap="$1">
<Text fontSize="$2" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}>
{t('share.title', 'Shared photo')}
</Text>
{photoId ? (
<Text fontSize="$5" fontWeight="$8">
#{photoId}
</Text>
) : null}
{eventName ? (
<Text fontSize="$2" color="$color" opacity={0.7}>
{eventName}
</Text>
) : null}
</YStack>
<Button
size="$3"
circular
backgroundColor={mutedSurface}
borderWidth={1}
borderColor={mutedBorder}
onPress={() => onOpenChange(false)}
aria-label={t('common.actions.close', 'Close')}
>
<X size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
<XStack gap="$2" flexWrap="wrap">
<Button
flex={1}
minWidth="45%"
borderRadius="$card"
backgroundColor={mutedSurface}
borderWidth={1}
borderColor={mutedBorder}
onPress={onShareNative}
disabled={loading}
>
<XStack alignItems="center" gap="$2">
<Share2 size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<YStack>
<Text fontSize="$2" fontWeight="$7">
{t('share.button', 'Share')}
</Text>
<Text fontSize="$1" color="$color" opacity={0.7}>
{t('share.title', 'Shared photo')}
</Text>
</YStack>
</XStack>
</Button>
<Button
flex={1}
minWidth="45%"
borderRadius="$card"
backgroundColor="#22C55E"
onPress={onShareWhatsApp}
disabled={loading}
>
<XStack alignItems="center" gap="$2">
<WhatsAppIcon width={18} height={18} />
<YStack>
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
{t('share.whatsapp', 'WhatsApp')}
</Text>
<Text fontSize="$1" color="rgba(255,255,255,0.8)">
{loading ? '...' : ''}
</Text>
</YStack>
</XStack>
</Button>
<Button
flex={1}
minWidth="45%"
borderRadius="$card"
backgroundColor="#38BDF8"
onPress={onShareMessages}
disabled={loading}
>
<XStack alignItems="center" gap="$2">
<MessageSquare size={16} color="#FFFFFF" />
<YStack>
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
{t('share.imessage', 'Messages')}
</Text>
<Text fontSize="$1" color="rgba(255,255,255,0.8)">
{loading ? '...' : ''}
</Text>
</YStack>
</XStack>
</Button>
<Button
flex={1}
minWidth="45%"
borderRadius="$card"
backgroundColor={mutedSurface}
borderWidth={1}
borderColor={mutedBorder}
onPress={onCopyLink}
disabled={loading}
>
<XStack alignItems="center" gap="$2">
<Copy size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<YStack>
<Text fontSize="$2" fontWeight="$7">
{t('share.copyLink', 'Copy link')}
</Text>
<Text fontSize="$1" color="$color" opacity={0.7}>
{loading ? t('share.loading', 'Loading...') : ''}
</Text>
</YStack>
</XStack>
</Button>
</XStack>
{url ? (
<Text fontSize="$1" color="$color" opacity={0.7} numberOfLines={1}>
{url}
</Text>
) : null}
</YStack>
{content}
</Sheet.Frame>
</Sheet>
);

View File

@@ -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<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) {
@@ -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<string, unknown> | 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<string, unknown>);
const mapped = mapFullPhoto(photo as Record<string, unknown>);
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() {
)}
</YStack>
{lightboxOpen ? (
{lightboxOpen || lightboxMounted ? (
<YStack
position="fixed"
top={0}
@@ -840,8 +973,11 @@ export default function GalleryScreen() {
alignItems="center"
justifyContent="center"
style={{
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.75)' : 'rgba(15, 23, 42, 0.45)',
backdropFilter: 'blur(12px)',
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
@@ -850,49 +986,18 @@ export default function GalleryScreen() {
style={{ position: 'absolute', inset: 0, zIndex: 0 }}
aria-label={t('common.actions.close', 'Close')}
/>
<YStack
width="100%"
maxWidth={780}
gap="$2"
padding="$3"
borderRadius="$bentoLg"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
style={{ boxShadow: hardShadow, zIndex: 1 }}
>
<XStack alignItems="center" justifyContent="space-between">
<YStack gap="$1">
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.1}>
{t('galleryPage.hero.label', 'Live-Galerie')}
</Text>
<Text fontSize="$4" fontWeight="$7">
{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>
<YStack width="100%" maxWidth={860} gap="$2" position="relative" style={{ zIndex: 1 }}>
<YStack
borderRadius="$bento"
backgroundColor="$muted"
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
borderRadius="$bentoLg"
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(255, 255, 255, 0.65)'}
overflow="hidden"
style={{ height: 'min(70vh, 520px)', boxShadow: cardShadow }}
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 ? (
@@ -907,45 +1012,72 @@ export default function GalleryScreen() {
onTouchEnd={handleTouchEnd}
style={{ zIndex: 0 }}
>
<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',
}}
/>
<Button
unstyled
onPress={handleLike}
aria-label={t('galleryPage.photo.likeAria', 'Like')}
style={{
position: 'absolute',
right: 14,
bottom: 14,
zIndex: 10,
}}
>
<YStack
padding="$2.5"
borderRadius="$pill"
backgroundColor={likedIds.has(lightboxPhoto.id) ? '#F43F5E' : mutedButton}
borderWidth={1}
borderColor={likedIds.has(lightboxPhoto.id) ? '#F43F5E' : mutedButtonBorder}
alignItems="center"
justifyContent="center"
style={{
boxShadow: isDark ? '0 8px 0 rgba(2, 6, 23, 0.55)' : '0 8px 0 rgba(15, 23, 42, 0.18)',
}}
>
<Heart size={22} color={likedIds.has(lightboxPhoto.id) ? '#FFFFFF' : (isDark ? '#F8FAFF' : '#0F172A')} />
</YStack>
</Button>
{(() => {
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">
@@ -965,16 +1097,91 @@ export default function GalleryScreen() {
)}
</YStack>
<XStack
position="absolute"
top={0}
left={0}
right={0}
alignItems="center"
justifyContent="space-between"
padding="$2"
borderTopWidth={1}
borderColor={bentoSurface.borderColor}
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.35)' : 'rgba(255, 255, 255, 0.8)'}
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)' }}
>
<Text fontSize="$1" color="$color" opacity={0.7}>
{lightboxIndex >= 0 ? `${lightboxIndex + 1} / ${displayPhotos.length}` : ''}
</Text>
<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"
@@ -984,89 +1191,80 @@ export default function GalleryScreen() {
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}
<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={goPrev}
onPress={() => {
transitionDirectionRef.current = 'prev';
goPrev();
}}
disabled={lightboxIndex <= 0}
opacity={lightboxIndex <= 0 ? 0.4 : 1}
>
<XStack alignItems="center" gap="$1.5">
<ChevronLeft size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$1" fontWeight="$6">
{t('galleryPage.lightbox.prev', 'Prev')}
</Text>
</XStack>
<ChevronLeft size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
<Button
unstyled
paddingHorizontal="$2.5"
paddingVertical="$1.5"
onPress={goNext}
onPress={() => {
transitionDirectionRef.current = 'next';
goNext();
}}
disabled={lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1}
opacity={lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1 ? 0.4 : 1}
>
<XStack alignItems="center" gap="$1.5">
<Text fontSize="$1" fontWeight="$6">
{t('galleryPage.lightbox.next', 'Next')}
</Text>
<ChevronRight size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
</XStack>
<ChevronRight size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
</XStack>
<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>
<XStack alignItems="center" justifyContent="space-between" flexWrap="wrap" gap="$2">
<Text fontSize="$3" fontWeight="$7">
{lightboxPhoto
? t(
'galleryPage.lightbox.likes',
{ count: likesById[lightboxPhoto.id] ?? lightboxPhoto.likes },
'{count} likes'
)
: ''}
</Text>
<XStack
gap="$1"
padding="$1"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
alignItems="center"
flexWrap="wrap"
justifyContent="flex-end"
>
<Button unstyled onPress={openShareSheet} paddingHorizontal="$3" paddingVertical="$2">
<XStack alignItems="center" gap="$2">
<Share2 size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{shareSheet.loading ? t('share.loading', 'Sharing...') : t('share.button', 'Share')}
</Text>
</XStack>
</Button>
</XStack>
</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)}
/>
</AppShell>
);
}
@@ -1083,9 +1281,50 @@ function mapFullPhoto(photo: Record<string, unknown>): 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<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;
return {
id,
imageUrl,
likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0,
taskId,
taskLabel,
emotion,
};
}

View File

@@ -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 (
<AppShell>
<YStack gap="$4">
<SurfaceCard>
<SurfaceCard position="relative">
<XStack alignItems="center" justifyContent="space-between">
<Button
size="$3"
@@ -592,6 +603,19 @@ export default function PhotoLightboxScreen() {
flexWrap="wrap"
justifyContent="flex-end"
>
<Button
unstyled
onPress={() => downloadPhoto(selected?.imageUrl ?? null, selected?.id ?? null)}
paddingHorizontal="$3"
paddingVertical="$2"
>
<XStack alignItems="center" gap="$2">
<Download size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{t('common.actions.download', 'Download')}
</Text>
</XStack>
</Button>
<Button
unstyled
onPress={handleLike}
@@ -626,24 +650,25 @@ export default function PhotoLightboxScreen() {
{t('lightbox.errors.notFound', 'Photo not found')}
</Text>
)}
<ShareSheet
open={shareSheet.loading || Boolean(shareSheet.url)}
onOpenChange={(open) => {
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)}
variant="inline"
/>
</SurfaceCard>
</YStack>
<ShareSheet
open={shareSheet.loading || Boolean(shareSheet.url)}
onOpenChange={(open) => {
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)}
/>
</AppShell>
);
}

View File

@@ -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);