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'); $since = $request->query('since');
$query = DB::table('photos') $query = DB::table('photos')
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
->leftJoin('emotions', 'photos.emotion_id', '=', 'emotions.id')
->select([ ->select([
'photos.id', 'photos.id',
'photos.file_path', 'photos.file_path',
@@ -2865,6 +2866,10 @@ class EventPublicController extends BaseController
'photos.created_at', 'photos.created_at',
'photos.ingest_source', 'photos.ingest_source',
'tasks.title as task_title', '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.event_id', $eventId)
->where('photos.status', 'approved') ->where('photos.status', 'approved')
@@ -2892,6 +2897,20 @@ class EventPublicController extends BaseController
$r->task_title = $this->firstLocalizedValue($r->task_title, $fallbacks, 'Unbenannte Aufgabe'); $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; $r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN;
return $r; return $r;

View File

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

View File

@@ -14,6 +14,7 @@ type ShareSheetProps = {
eventName?: string | null; eventName?: string | null;
url?: string | null; url?: string | null;
loading?: boolean; loading?: boolean;
variant?: 'modal' | 'inline';
onShareNative: () => void; onShareNative: () => void;
onShareWhatsApp: () => void; onShareWhatsApp: () => void;
onShareMessages: () => void; onShareMessages: () => void;
@@ -36,6 +37,7 @@ export default function ShareSheet({
eventName, eventName,
url, url,
loading = false, loading = false,
variant = 'modal',
onShareNative, onShareNative,
onShareWhatsApp, onShareWhatsApp,
onShareMessages, onShareMessages,
@@ -45,6 +47,199 @@ export default function ShareSheet({
const { isDark } = useGuestThemeVariant(); const { isDark } = useGuestThemeVariant();
const mutedSurface = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'; 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 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 ( return (
<Sheet <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.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.Frame padding="$4" backgroundColor="$surface" borderTopLeftRadius="$6" borderTopRightRadius="$6">
<Sheet.Handle height={5} width={52} backgroundColor="#CBD5E1" borderRadius={999} marginBottom="$3" /> <Sheet.Handle height={5} width={52} backgroundColor="#CBD5E1" borderRadius={999} marginBottom="$3" />
<YStack gap="$3"> {content}
<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>
</Sheet.Frame> </Sheet.Frame>
</Sheet> </Sheet>
); );

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button'; 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 AppShell from '../components/AppShell';
import PhotoFrameTile from '../components/PhotoFrameTile'; import PhotoFrameTile from '../components/PhotoFrameTile';
import ShareSheet from '../components/ShareSheet'; import ShareSheet from '../components/ShareSheet';
@@ -27,12 +27,26 @@ type GalleryTile = {
createdAt?: string | null; createdAt?: string | null;
ingestSource?: string | null; ingestSource?: string | null;
sessionId?: string | null; sessionId?: string | null;
taskId?: number | null;
taskLabel?: string | null;
emotion?: {
name?: string | null;
icon?: string | null;
color?: string | null;
} | null;
}; };
type LightboxPhoto = { type LightboxPhoto = {
id: number; id: number;
imageUrl: string; imageUrl: string;
likes: number; likes: number;
taskId?: number | null;
taskLabel?: string | null;
emotion?: {
name?: string | null;
icon?: string | null;
color?: string | null;
} | null;
}; };
function normalizeImageUrl(src?: string | null) { function normalizeImageUrl(src?: string | null) {
@@ -87,6 +101,16 @@ export default function GalleryScreen() {
const pendingNotFoundRef = React.useRef(false); const pendingNotFoundRef = React.useRef(false);
const photosRef = React.useRef<GalleryTile[]>([]); const photosRef = React.useRef<GalleryTile[]>([]);
const galleryLoadingRef = React.useRef(Boolean(token)); 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(() => { React.useEffect(() => {
if (!token) { if (!token) {
@@ -116,6 +140,44 @@ export default function GalleryScreen() {
?? (record.url as string | null | undefined) ?? (record.url as string | null | undefined)
?? (record.image_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 { return {
id, id,
imageUrl, imageUrl,
@@ -123,6 +185,9 @@ export default function GalleryScreen() {
createdAt: typeof record.created_at === 'string' ? record.created_at : null, createdAt: typeof record.created_at === 'string' ? record.created_at : null,
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null, ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
sessionId: typeof record.session_id === 'string' ? record.session_id : null, sessionId: typeof record.session_id === 'string' ? record.session_id : null,
taskId,
taskLabel,
emotion,
}; };
}) })
.filter((item) => item.id && item.imageUrl); .filter((item) => item.id && item.imageUrl);
@@ -310,7 +375,14 @@ export default function GalleryScreen() {
} }
const seed = lightboxSelected 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; : null;
if (seed) { if (seed) {
setLightboxPhoto(seed); setLightboxPhoto(seed);
@@ -341,7 +413,7 @@ export default function GalleryScreen() {
const photo = await fetchPhoto(selectedPhotoId, locale); const photo = await fetchPhoto(selectedPhotoId, locale);
if (!active) return; if (!active) return;
if (photo) { if (photo) {
const mapped = mapFullPhoto(photo as Record<string, unknown>); const mapped = mapFullPhoto(photo as Record<string, unknown>);
if (mapped) { if (mapped) {
setLightboxPhoto(mapped); setLightboxPhoto(mapped);
setLikesById((prev) => ({ ...prev, [mapped.id]: mapped.likes })); setLikesById((prev) => ({ ...prev, [mapped.id]: mapped.likes }));
@@ -373,6 +445,36 @@ export default function GalleryScreen() {
}; };
}, [lightboxOpen, lightboxSelected, locale, selectedPhotoId]); }, [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(() => { React.useEffect(() => {
if (!lightboxOpen) { if (!lightboxOpen) {
document.body.style.overflow = ''; document.body.style.overflow = '';
@@ -391,6 +493,22 @@ export default function GalleryScreen() {
}; };
}, [lightboxOpen]); }, [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(() => { React.useEffect(() => {
if (!pendingNotFoundRef.current) return; if (!pendingNotFoundRef.current) return;
if (loading || galleryLoadingRef.current) return; if (loading || galleryLoadingRef.current) return;
@@ -419,6 +537,7 @@ export default function GalleryScreen() {
if (lightboxIndex <= 0) return; if (lightboxIndex <= 0) return;
const prevId = displayPhotos[lightboxIndex - 1]?.id; const prevId = displayPhotos[lightboxIndex - 1]?.id;
if (prevId) { if (prevId) {
transitionDirectionRef.current = 'prev';
openLightbox(prevId); openLightbox(prevId);
} }
}, [displayPhotos, lightboxIndex, openLightbox]); }, [displayPhotos, lightboxIndex, openLightbox]);
@@ -427,6 +546,7 @@ export default function GalleryScreen() {
if (lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1) return; if (lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1) return;
const nextId = displayPhotos[lightboxIndex + 1]?.id; const nextId = displayPhotos[lightboxIndex + 1]?.id;
if (nextId) { if (nextId) {
transitionDirectionRef.current = 'next';
openLightbox(nextId); openLightbox(nextId);
} }
}, [displayPhotos, lightboxIndex, openLightbox]); }, [displayPhotos, lightboxIndex, openLightbox]);
@@ -539,6 +659,17 @@ export default function GalleryScreen() {
[closeShareSheet, copyLink, shareText, shareTitle] [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) => { const handleTouchStart = (event: React.TouchEvent) => {
touchStartX.current = event.touches[0]?.clientX ?? null; touchStartX.current = event.touches[0]?.clientX ?? null;
}; };
@@ -558,9 +689,11 @@ export default function GalleryScreen() {
return; return;
} }
if (delta > 0) { if (delta > 0) {
transitionDirectionRef.current = 'prev';
goPrev(); goPrev();
return; return;
} }
transitionDirectionRef.current = 'next';
goNext(); goNext();
}; };
@@ -828,7 +961,7 @@ export default function GalleryScreen() {
)} )}
</YStack> </YStack>
{lightboxOpen ? ( {lightboxOpen || lightboxMounted ? (
<YStack <YStack
position="fixed" position="fixed"
top={0} top={0}
@@ -840,8 +973,11 @@ export default function GalleryScreen() {
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
style={{ style={{
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.75)' : 'rgba(15, 23, 42, 0.45)', backgroundColor: isDark
backdropFilter: 'blur(12px)', ? `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 <Button
@@ -850,49 +986,18 @@ export default function GalleryScreen() {
style={{ position: 'absolute', inset: 0, zIndex: 0 }} style={{ position: 'absolute', inset: 0, zIndex: 0 }}
aria-label={t('common.actions.close', 'Close')} aria-label={t('common.actions.close', 'Close')}
/> />
<YStack <YStack width="100%" maxWidth={860} gap="$2" position="relative" style={{ zIndex: 1 }}>
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 <YStack
borderRadius="$bento" borderRadius="$bentoLg"
backgroundColor="$muted" backgroundColor={isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(255, 255, 255, 0.65)'}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
overflow="hidden" 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"> <YStack flex={1} alignItems="center" justifyContent="center" padding="$2">
{lightboxPhoto ? ( {lightboxPhoto ? (
@@ -907,45 +1012,72 @@ export default function GalleryScreen() {
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
style={{ zIndex: 0 }} style={{ zIndex: 0 }}
> >
<img {(() => {
src={lightboxPhoto.imageUrl} const { from, to, direction, active } = lightboxTransition;
alt={t('galleryPage.photo.alt', { id: lightboxPhoto.id, suffix: '' }, `Foto ${lightboxPhoto.id}`)} const canAnimate = Boolean(from && to && from.id !== to.id);
style={{ const dir = direction === 'next' ? 1 : -1;
width: '100%', const offset = `${dir * 24}%`;
height: '100%', const transition = 'transform 420ms cubic-bezier(0.22, 1, 0.36, 1), opacity 420ms ease';
objectFit: 'contain',
position: 'absolute', if (!canAnimate || !to) {
inset: 0, return (
zIndex: 0, <img
pointerEvents: 'none', src={lightboxPhoto.imageUrl}
}} alt={t('galleryPage.photo.alt', { id: lightboxPhoto.id, suffix: '' }, `Foto ${lightboxPhoto.id}`)}
/> style={{
<Button width: '100%',
unstyled height: '100%',
onPress={handleLike} objectFit: 'contain',
aria-label={t('galleryPage.photo.likeAria', 'Like')} position: 'absolute',
style={{ inset: 0,
position: 'absolute', zIndex: 0,
right: 14, pointerEvents: 'none',
bottom: 14, }}
zIndex: 10, />
}} );
> }
<YStack
padding="$2.5" return (
borderRadius="$pill" <>
backgroundColor={likedIds.has(lightboxPhoto.id) ? '#F43F5E' : mutedButton} {from ? (
borderWidth={1} <img
borderColor={likedIds.has(lightboxPhoto.id) ? '#F43F5E' : mutedButtonBorder} src={from.imageUrl}
alignItems="center" alt={t('galleryPage.photo.alt', { id: from.id, suffix: '' }, `Foto ${from.id}`)}
justifyContent="center" style={{
style={{ width: '100%',
boxShadow: isDark ? '0 8px 0 rgba(2, 6, 23, 0.55)' : '0 8px 0 rgba(15, 23, 42, 0.18)', height: '100%',
}} objectFit: 'contain',
> position: 'absolute',
<Heart size={22} color={likedIds.has(lightboxPhoto.id) ? '#FFFFFF' : (isDark ? '#F8FAFF' : '#0F172A')} /> inset: 0,
</YStack> zIndex: 0,
</Button> 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>
) : ( ) : (
<YStack alignItems="center" gap="$2"> <YStack alignItems="center" gap="$2">
@@ -965,16 +1097,91 @@ export default function GalleryScreen() {
)} )}
</YStack> </YStack>
<XStack <XStack
position="absolute"
top={0}
left={0}
right={0}
alignItems="center" alignItems="center"
justifyContent="space-between" justifyContent="space-between"
padding="$2" padding="$3"
borderTopWidth={1} borderRadius={0}
borderColor={bentoSurface.borderColor} backgroundColor={isDark ? 'rgba(12, 16, 32, 0.72)' : 'rgba(255, 255, 255, 0.9)'}
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.35)' : 'rgba(255, 255, 255, 0.8)'} borderWidth={1}
borderColor={mutedButtonBorder}
style={{ backdropFilter: 'blur(10px)' }}
> >
<Text fontSize="$1" color="$color" opacity={0.7}> <YStack gap="$0.5" flexShrink={1}>
{lightboxIndex >= 0 ? `${lightboxIndex + 1} / ${displayPhotos.length}` : ''} <Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.1}>
</Text> {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 <XStack
gap="$1" gap="$1"
padding="$1" padding="$1"
@@ -984,89 +1191,80 @@ export default function GalleryScreen() {
borderColor={mutedButtonBorder} borderColor={mutedButtonBorder}
alignItems="center" 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 <Button
unstyled unstyled
paddingHorizontal="$2.5" paddingHorizontal="$2.5"
paddingVertical="$1.5" paddingVertical="$1.5"
onPress={goPrev} onPress={() => {
transitionDirectionRef.current = 'prev';
goPrev();
}}
disabled={lightboxIndex <= 0} disabled={lightboxIndex <= 0}
opacity={lightboxIndex <= 0 ? 0.4 : 1} opacity={lightboxIndex <= 0 ? 0.4 : 1}
> >
<XStack alignItems="center" gap="$1.5"> <ChevronLeft size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<ChevronLeft size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$1" fontWeight="$6">
{t('galleryPage.lightbox.prev', 'Prev')}
</Text>
</XStack>
</Button> </Button>
<Button <Button
unstyled unstyled
paddingHorizontal="$2.5" paddingHorizontal="$2.5"
paddingVertical="$1.5" paddingVertical="$1.5"
onPress={goNext} onPress={() => {
transitionDirectionRef.current = 'next';
goNext();
}}
disabled={lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1} disabled={lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1}
opacity={lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1 ? 0.4 : 1} opacity={lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1 ? 0.4 : 1}
> >
<XStack alignItems="center" gap="$1.5"> <ChevronRight size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$1" fontWeight="$6">
{t('galleryPage.lightbox.next', 'Next')}
</Text>
<ChevronRight size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
</XStack>
</Button> </Button>
</XStack> </XStack>
</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> </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>
</YStack> </YStack>
) : null} ) : 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> </AppShell>
); );
} }
@@ -1083,9 +1281,50 @@ function mapFullPhoto(photo: Record<string, unknown>): LightboxPhoto | null {
?? (photo.image_url as string | null | undefined) ?? (photo.image_url as string | null | undefined)
); );
if (!imageUrl) return null; 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 { return {
id, id,
imageUrl, imageUrl,
likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0, 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 { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button'; 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 { useGesture } from '@use-gesture/react';
import { animated, to, useSpring } from '@react-spring/web'; import { animated, to, useSpring } from '@react-spring/web';
import AppShell from '../components/AppShell'; import AppShell from '../components/AppShell';
@@ -370,6 +370,17 @@ export default function PhotoLightboxScreen() {
[closeShareSheet, copyLink, shareText, shareTitle] [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( const bind = useGesture(
{ {
onDrag: ({ down, movement: [mx, my], offset: [ox, oy], last, event }) => { onDrag: ({ down, movement: [mx, my], offset: [ox, oy], last, event }) => {
@@ -449,7 +460,7 @@ export default function PhotoLightboxScreen() {
return ( return (
<AppShell> <AppShell>
<YStack gap="$4"> <YStack gap="$4">
<SurfaceCard> <SurfaceCard position="relative">
<XStack alignItems="center" justifyContent="space-between"> <XStack alignItems="center" justifyContent="space-between">
<Button <Button
size="$3" size="$3"
@@ -592,6 +603,19 @@ export default function PhotoLightboxScreen() {
flexWrap="wrap" flexWrap="wrap"
justifyContent="flex-end" 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 <Button
unstyled unstyled
onPress={handleLike} onPress={handleLike}
@@ -626,24 +650,25 @@ export default function PhotoLightboxScreen() {
{t('lightbox.errors.notFound', 'Photo not found')} {t('lightbox.errors.notFound', 'Photo not found')}
</Text> </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> </SurfaceCard>
</YStack> </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> </AppShell>
); );
} }

View File

@@ -29,6 +29,8 @@ class EventPhotosLocaleTest extends TestCase
$emotion = Emotion::factory()->create([ $emotion = Emotion::factory()->create([
'name' => ['de' => 'Freude', 'en' => 'Joy'], 'name' => ['de' => 'Freude', 'en' => 'Joy'],
'icon' => '🙂',
'color' => '#FF00AA',
]); ]);
$task = Task::factory()->create([ $task = Task::factory()->create([
@@ -52,6 +54,9 @@ class EventPhotosLocaleTest extends TestCase
$responseEn->assertOk(); $responseEn->assertOk();
$responseEn->assertHeader('X-Content-Locale', 'en'); $responseEn->assertHeader('X-Content-Locale', 'en');
$responseEn->assertJsonFragment(['task_title' => 'Kiss Moment']); $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'); $etag = $responseEn->headers->get('ETag');
$this->assertNotEmpty($etag); $this->assertNotEmpty($etag);