Files
fotospiel-app/resources/js/guest-v2/screens/SlideshowScreen.tsx
Codex Agent 298a8375b6
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Update guest v2 branding and theming
2026-02-03 15:18:44 +01:00

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>
);
}