upgrade to tamagui v2 and guest pwa overhaul
This commit is contained in:
186
resources/js/guest-v2/screens/SlideshowScreen.tsx
Normal file
186
resources/js/guest-v2/screens/SlideshowScreen.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ChevronLeft, ChevronRight, Pause, Play, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { useEventData } from '../context/EventDataContext';
|
||||
import { fetchGallery, type GalleryPhoto } from '../services/photosApi';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
|
||||
function normalizeImageUrl(src?: string | null) {
|
||||
if (!src) return '';
|
||||
if (/^https?:/i.test(src)) return src;
|
||||
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
|
||||
if (!cleanPath.startsWith('storage/')) cleanPath = `storage/${cleanPath}`;
|
||||
return `/${cleanPath}`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
export default function SlideshowScreen() {
|
||||
const { token, event } = useEventData();
|
||||
const { t } = useTranslation();
|
||||
const [photos, setPhotos] = React.useState<GalleryPhoto[]>([]);
|
||||
const [index, setIndex] = React.useState(0);
|
||||
const [paused, setPaused] = React.useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
||||
const intervalRef = React.useRef<number | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
document.body.classList.add('guest-immersive');
|
||||
return () => {
|
||||
document.body.classList.remove('guest-immersive');
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!token) return;
|
||||
let active = true;
|
||||
fetchGallery(token, { limit: 50 })
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
setPhotos(response.data ?? []);
|
||||
setIndex(0);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setPhotos([]);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (paused || photos.length <= 1) {
|
||||
if (intervalRef.current) window.clearInterval(intervalRef.current);
|
||||
return;
|
||||
}
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
setIndex((prev) => (prev + 1) % photos.length);
|
||||
}, 5000);
|
||||
return () => {
|
||||
if (intervalRef.current) window.clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [paused, photos.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleFullscreen = () => setIsFullscreen(Boolean(document.fullscreenElement));
|
||||
document.addEventListener('fullscreenchange', handleFullscreen);
|
||||
handleFullscreen();
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreen);
|
||||
}, []);
|
||||
|
||||
const current = photos[index] as Record<string, unknown> | undefined;
|
||||
const imageUrl = normalizeImageUrl(
|
||||
(current?.full_url as string | undefined)
|
||||
?? (current?.thumbnail_url as string | undefined)
|
||||
?? (current?.thumbnail_path as string | undefined)
|
||||
?? (current?.file_path as string | undefined)
|
||||
?? (current?.url as string | undefined)
|
||||
?? (current?.image_url as string | undefined)
|
||||
);
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
await document.documentElement.requestFullscreen?.();
|
||||
} else {
|
||||
await document.exitFullscreen?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Fullscreen toggle failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-black text-white">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between px-6 py-4 text-sm">
|
||||
<span className="font-semibold text-white" style={{ fontFamily: 'var(--guest-heading-font)' }}>
|
||||
{event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
|
||||
{t('galleryPage.title', 'Gallery')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{imageUrl ? (
|
||||
<motion.div
|
||||
key={`bg-${imageUrl}`}
|
||||
className="pointer-events-none absolute inset-0 z-0"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.35 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
style={{
|
||||
backgroundImage: `url(${imageUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
filter: 'blur(30px)',
|
||||
transform: 'scale(1.08)',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={imageUrl || index}
|
||||
className="absolute inset-0 z-10"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 1.02 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
style={{
|
||||
backgroundImage: imageUrl ? `url(${imageUrl})` : undefined,
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
filter: imageUrl ? 'drop-shadow(0 20px 40px rgba(0,0,0,0.6))' : undefined,
|
||||
}}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
|
||||
{photos.length === 0 ? (
|
||||
<div className="z-10 max-w-md space-y-2 px-6 text-center text-white/80">
|
||||
<p className="text-lg font-semibold text-white">{t('galleryPublic.emptyTitle', 'Noch keine Fotos')}</p>
|
||||
<p className="text-sm text-white/70">{t('galleryPublic.emptyDescription', 'Sobald Fotos freigegeben sind, erscheinen sie hier.')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="absolute bottom-6 left-1/2 z-20 flex -translate-x-1/2 items-center gap-3 rounded-full border border-white/10 bg-black/60 px-4 py-2 text-xs text-white/80 shadow-lg backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white"
|
||||
onClick={() => setPaused((prev) => !prev)}
|
||||
>
|
||||
{paused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
|
||||
<span>{paused ? t('liveShowPlayer.controls.play', 'Play') : t('liveShowPlayer.controls.pause', 'Pause')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white"
|
||||
onClick={() => {
|
||||
if (!photos.length) return;
|
||||
setIndex((prev) => (prev - 1 + photos.length) % photos.length);
|
||||
}}
|
||||
disabled={photos.length <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white"
|
||||
onClick={() => {
|
||||
if (!photos.length) return;
|
||||
setIndex((prev) => (prev + 1) % photos.length);
|
||||
}}
|
||||
disabled={photos.length <= 1}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white"
|
||||
onClick={toggleFullscreen}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user