Add emotion data and lightbox share/download
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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,18 +47,26 @@ 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);
|
||||||
|
|
||||||
return (
|
React.useEffect(() => {
|
||||||
<Sheet
|
if (variant !== 'inline') return;
|
||||||
open={open}
|
if (open) {
|
||||||
onOpenChange={onOpenChange}
|
setInlineMounted(true);
|
||||||
snapPoints={[60]}
|
const raf = window.requestAnimationFrame(() => {
|
||||||
position={open ? 0 : -1}
|
setInlineVisible(true);
|
||||||
modal
|
});
|
||||||
>
|
return () => window.cancelAnimationFrame(raf);
|
||||||
<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">
|
setInlineVisible(false);
|
||||||
<Sheet.Handle height={5} width={52} backgroundColor="#CBD5E1" borderRadius={999} marginBottom="$3" />
|
const timeout = window.setTimeout(() => {
|
||||||
|
setInlineMounted(false);
|
||||||
|
}, 220);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [open, variant]);
|
||||||
|
|
||||||
|
const content = (
|
||||||
<YStack gap="$3">
|
<YStack gap="$3">
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<YStack gap="$1">
|
<YStack gap="$1">
|
||||||
@@ -180,6 +190,69 @@ export default function ShareSheet({
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</YStack>
|
</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
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
snapPoints={[60]}
|
||||||
|
position={open ? 0 : -1}
|
||||||
|
modal
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
{content}
|
||||||
</Sheet.Frame>
|
</Sheet.Frame>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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 width="100%" maxWidth={860} gap="$2" position="relative" style={{ zIndex: 1 }}>
|
||||||
<YStack
|
<YStack
|
||||||
width="100%"
|
|
||||||
maxWidth={780}
|
|
||||||
gap="$2"
|
|
||||||
padding="$3"
|
|
||||||
borderRadius="$bentoLg"
|
borderRadius="$bentoLg"
|
||||||
backgroundColor={bentoSurface.backgroundColor}
|
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(255, 255, 255, 0.65)'}
|
||||||
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
|
|
||||||
borderRadius="$bento"
|
|
||||||
backgroundColor="$muted"
|
|
||||||
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,6 +1012,15 @@ export default function GalleryScreen() {
|
|||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
style={{ zIndex: 0 }}
|
style={{ zIndex: 0 }}
|
||||||
>
|
>
|
||||||
|
{(() => {
|
||||||
|
const { from, to, direction, active } = lightboxTransition;
|
||||||
|
const canAnimate = Boolean(from && to && from.id !== to.id);
|
||||||
|
const dir = direction === 'next' ? 1 : -1;
|
||||||
|
const offset = `${dir * 24}%`;
|
||||||
|
const transition = 'transform 420ms cubic-bezier(0.22, 1, 0.36, 1), opacity 420ms ease';
|
||||||
|
|
||||||
|
if (!canAnimate || !to) {
|
||||||
|
return (
|
||||||
<img
|
<img
|
||||||
src={lightboxPhoto.imageUrl}
|
src={lightboxPhoto.imageUrl}
|
||||||
alt={t('galleryPage.photo.alt', { id: lightboxPhoto.id, suffix: '' }, `Foto ${lightboxPhoto.id}`)}
|
alt={t('galleryPage.photo.alt', { id: lightboxPhoto.id, suffix: '' }, `Foto ${lightboxPhoto.id}`)}
|
||||||
@@ -920,32 +1034,50 @@ export default function GalleryScreen() {
|
|||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
);
|
||||||
unstyled
|
}
|
||||||
onPress={handleLike}
|
|
||||||
aria-label={t('galleryPage.photo.likeAria', 'Like')}
|
return (
|
||||||
|
<>
|
||||||
|
{from ? (
|
||||||
|
<img
|
||||||
|
src={from.imageUrl}
|
||||||
|
alt={t('galleryPage.photo.alt', { id: from.id, suffix: '' }, `Foto ${from.id}`)}
|
||||||
style={{
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: 14,
|
inset: 0,
|
||||||
bottom: 14,
|
zIndex: 0,
|
||||||
zIndex: 10,
|
pointerEvents: 'none',
|
||||||
|
opacity: active ? 0 : 1,
|
||||||
|
transform: active ? `translateX(${dir * -24}%) scale(0.96)` : 'translateX(0) scale(1)',
|
||||||
|
transition,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<YStack
|
) : null}
|
||||||
padding="$2.5"
|
<img
|
||||||
borderRadius="$pill"
|
src={to.imageUrl}
|
||||||
backgroundColor={likedIds.has(lightboxPhoto.id) ? '#F43F5E' : mutedButton}
|
alt={t('galleryPage.photo.alt', { id: to.id, suffix: '' }, `Foto ${to.id}`)}
|
||||||
borderWidth={1}
|
|
||||||
borderColor={likedIds.has(lightboxPhoto.id) ? '#F43F5E' : mutedButtonBorder}
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="center"
|
|
||||||
style={{
|
style={{
|
||||||
boxShadow: isDark ? '0 8px 0 rgba(2, 6, 23, 0.55)' : '0 8px 0 rgba(15, 23, 42, 0.18)',
|
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',
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Heart size={22} color={likedIds.has(lightboxPhoto.id) ? '#FFFFFF' : (isDark ? '#F8FAFF' : '#0F172A')} />
|
</>
|
||||||
</YStack>
|
);
|
||||||
</Button>
|
})()}
|
||||||
</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)' }}
|
||||||
>
|
>
|
||||||
|
<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}>
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||||
{lightboxIndex >= 0 ? `${lightboxIndex + 1} / ${displayPhotos.length}` : ''}
|
{lightboxIndex >= 0 ? `${lightboxIndex + 1} / ${displayPhotos.length}` : ''}
|
||||||
</Text>
|
</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,73 +1191,59 @@ export default function GalleryScreen() {
|
|||||||
borderColor={mutedButtonBorder}
|
borderColor={mutedButtonBorder}
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
>
|
>
|
||||||
|
{lightboxPhoto ? (
|
||||||
<Button
|
<Button
|
||||||
unstyled
|
unstyled
|
||||||
paddingHorizontal="$2.5"
|
paddingHorizontal="$2.5"
|
||||||
paddingVertical="$1.5"
|
paddingVertical="$1.5"
|
||||||
onPress={goPrev}
|
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={() => {
|
||||||
|
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>
|
||||||
</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
|
<ShareSheet
|
||||||
open={shareSheet.loading || Boolean(shareSheet.url)}
|
open={shareSheet.loading || Boolean(shareSheet.url)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@@ -1066,7 +1259,12 @@ export default function GalleryScreen() {
|
|||||||
onShareWhatsApp={() => shareWhatsApp(shareSheet.url)}
|
onShareWhatsApp={() => shareWhatsApp(shareSheet.url)}
|
||||||
onShareMessages={() => shareMessages(shareSheet.url)}
|
onShareMessages={() => shareMessages(shareSheet.url)}
|
||||||
onCopyLink={() => copyLink(shareSheet.url)}
|
onCopyLink={() => copyLink(shareSheet.url)}
|
||||||
|
variant="inline"
|
||||||
/>
|
/>
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
) : null}
|
||||||
</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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +650,6 @@ export default function PhotoLightboxScreen() {
|
|||||||
{t('lightbox.errors.notFound', 'Photo not found')}
|
{t('lightbox.errors.notFound', 'Photo not found')}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</SurfaceCard>
|
|
||||||
</YStack>
|
|
||||||
<ShareSheet
|
<ShareSheet
|
||||||
open={shareSheet.loading || Boolean(shareSheet.url)}
|
open={shareSheet.loading || Boolean(shareSheet.url)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@@ -643,7 +665,10 @@ export default function PhotoLightboxScreen() {
|
|||||||
onShareWhatsApp={() => shareWhatsApp(shareSheet.url)}
|
onShareWhatsApp={() => shareWhatsApp(shareSheet.url)}
|
||||||
onShareMessages={() => shareMessages(shareSheet.url)}
|
onShareMessages={() => shareMessages(shareSheet.url)}
|
||||||
onCopyLink={() => copyLink(shareSheet.url)}
|
onCopyLink={() => copyLink(shareSheet.url)}
|
||||||
|
variant="inline"
|
||||||
/>
|
/>
|
||||||
|
</SurfaceCard>
|
||||||
|
</YStack>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user