191 lines
7.3 KiB
TypeScript
191 lines
7.3 KiB
TypeScript
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 EventLogo from '../components/EventLogo';
|
|
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">
|
|
<div className="flex items-center gap-2">
|
|
<EventLogo name={event?.name ?? t('galleryPage.hero.eventFallback', 'Event')} icon={event?.type?.icon ?? null} size="s" />
|
|
<span className="font-semibold text-white" style={{ fontFamily: 'var(--guest-heading-font)' }}>
|
|
{event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
|
|
</span>
|
|
</div>
|
|
<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>
|
|
);
|
|
}
|