Add emotion data and lightbox share/download
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user