diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7fd92407..169a0297 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { ThemeProvider } from './contexts/ThemeContext'; import { GalleryPage } from './pages/GalleryPage'; import { ClientAccessPage } from './pages/ClientAccessPage'; import { PreviewPage } from './pages/gallery/PreviewPage'; +const SlideshowPage = lazy(() => import('./pages/gallery/SlideshowPage').then((m) => ({ default: m.SlideshowPage }))); import { LegalPage } from './pages/public/LegalPage'; import { AdminLoginPage, @@ -159,6 +160,13 @@ function App() { {/* Public gallery routes */} } /> + {/* Live Slideshow ("Diashow") — token-only fullscreen kiosk. + Self-manages its session token; no GalleryAuthProvider. */} + }> + + + } /> diff --git a/frontend/src/pages/gallery/SlideshowPage.tsx b/frontend/src/pages/gallery/SlideshowPage.tsx new file mode 100644 index 00000000..2453684f --- /dev/null +++ b/frontend/src/pages/gallery/SlideshowPage.tsx @@ -0,0 +1,440 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import { galleryService } from '../../services/gallery.service'; +import { slideshowService, type SlideshowSettings } from '../../services/slideshow.service'; +import { storeGalleryToken, setActiveGallerySlug } from '../../utils/galleryAuthStorage'; +import { buildResourceUrl } from '../../utils/url'; +import type { Photo } from '../../types'; + +const DEFAULT_SETTINGS: SlideshowSettings = { + interval_ms: 5000, + transition: 'crossfade', + transition_ms: 800, + colorfilter: 'none', + watermark: null, +}; + +// How often the running show re-checks settings + photo count (tiny payload). +const STATE_POLL_MS = 3000; + +// Prefer the aspect-preserved preview (≤1920px) over the full original; fall +// back to the standard url. Always absolutised so it works whether the API is +// same-origin or an explicit absolute base. +function photoSrc(photo: Photo): string { + return buildResourceUrl(photo.preview_url || photo.hero_url || photo.url); +} + +// CSS `filter` applied directly to the image for filters that are pure tone +// adjustments. Tint/vignette filters are drawn as a separate overlay instead. +function imageFilter(colorfilter: SlideshowSettings['colorfilter']): string { + switch (colorfilter) { + case 'bw': return 'grayscale(1)'; + case 'sepia': return 'sepia(0.75)'; + default: return 'none'; + } +} + +// Overlay for tint/vignette filters (drawn above the image, below the +// watermark). Returns null when the filter needs no overlay. +function colorOverlayStyle(colorfilter: SlideshowSettings['colorfilter']): React.CSSProperties | null { + switch (colorfilter) { + case 'warm': + return { background: 'rgba(255, 160, 60, 0.18)', mixBlendMode: 'soft-light' }; + case 'cool': + return { background: 'rgba(60, 140, 255, 0.18)', mixBlendMode: 'soft-light' }; + case 'vignette': + return { background: 'radial-gradient(ellipse at center, rgba(0,0,0,0) 55%, rgba(0,0,0,0.55) 100%)' }; + default: + return null; + } +} + +function watermarkCorner(position: string): React.CSSProperties { + const pad = '4vmin'; + switch (position) { + case 'top-left': return { top: pad, left: pad }; + case 'top-right': return { top: pad, right: pad }; + case 'bottom-left': return { bottom: pad, left: pad }; + case 'bottom-right': + default: return { bottom: pad, right: pad }; + } +} + +type Phase = 'splash' | 'running' | 'ended'; + +export function SlideshowPage() { + const { slug = '', token = '' } = useParams<{ slug: string; token: string }>(); + const { t } = useTranslation(); + + const [phase, setPhase] = useState('splash'); + const [error, setError] = useState(null); + const [eventName, setEventName] = useState(''); + const [photos, setPhotos] = useState([]); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + + // Two-buffer crossfade: each buffer holds an index into `photos`. `active` + // says which buffer is on top. Advancing loads the next index into the + // hidden buffer, then flips `active` so CSS opacity transitions it in. + const [buffers, setBuffers] = useState<[number, number]>([0, 0]); + const [active, setActive] = useState<0 | 1>(0); + const [cursorHidden, setCursorHidden] = useState(false); + // Bumped on each dip transition to re-trigger the white/black flash overlay. + const [flash, setFlash] = useState(0); + + // Refs so timers/pollers read the latest value without re-subscribing. + const photosRef = useRef(photos); + const settingsRef = useRef(settings); + const activeRef = useRef<0 | 1>(active); + const positionRef = useRef(0); // index currently shown in the active buffer + photosRef.current = photos; + settingsRef.current = settings; + activeRef.current = active; + + // ----- New uploads APPEND quietly: keep existing order/position, only add. ----- + const mergePhotos = useCallback((incoming: Photo[]) => { + setPhotos((prev) => { + if (prev.length === 0) return incoming; + const seen = new Set(prev.map((p) => p.id)); + const added = incoming.filter((p) => !seen.has(p.id)); + return added.length ? [...prev, ...added] : prev; + }); + }, []); + + const loadPhotos = useCallback(async () => { + const data = await galleryService.getGalleryPhotos(slug); + mergePhotos(data.photos || []); + }, [slug, mergePhotos]); + + // Fetch + fully decode an image so the next swap paints instantly (decode() + // forces the browser to rasterise it now rather than on first display, which + // is what otherwise makes the transition stutter). Best-effort. + const preloadDecode = useCallback(async (photo?: Photo) => { + if (!photo) return; + try { + const img = new Image(); + img.src = photoSrc(photo); + if (img.decode) await img.decode(); + } catch { + /* decode can reject on cache/abort — harmless, the still loads */ + } + }, []); + + // ----- Advance one slide (wraps at the end). ----- + const advance = useCallback(() => { + const list = photosRef.current; + if (list.length < 2) return; + const next = (positionRef.current + 1) % list.length; + positionRef.current = next; + const hidden: 0 | 1 = activeRef.current === 0 ? 1 : 0; + setBuffers((prev) => { + const updated: [number, number] = [...prev] as [number, number]; + updated[hidden] = next; + return updated; + }); + setActive(hidden); + const tr = settingsRef.current.transition; + if (tr === 'dipwhite' || tr === 'dipblack') setFlash((f) => f + 1); + }, []); + + // ----- Start: gesture-driven (fullscreen needs a user gesture). ----- + const start = useCallback(async () => { + try { + setError(null); + const session = await slideshowService.getSession(slug, token); + // Store the minted token so XHR photo calls carry it; the cookie set by + // /session covers requests. + storeGalleryToken(slug, session.token); + setActiveGallerySlug(slug); + setEventName(session.event.event_name || ''); + setSettings(session.settings || DEFAULT_SETTINGS); + + // Load the list and DECODE the first slide (and the next) before we flip + // to running, so playback starts on an already-rasterised image instead + // of struggling on the first transition. + const data = await galleryService.getGalleryPhotos(slug); + const list = data.photos || []; + setPhotos(list); + await preloadDecode(list[0]); + void preloadDecode(list[1]); + positionRef.current = 0; + setBuffers([0, 0]); + setActive(0); + setPhase('running'); + + // Enter fullscreen (best-effort — projector/kiosk; ignore if blocked). + try { + await document.documentElement.requestFullscreen?.(); + } catch { + /* fullscreen denied — keep running windowed */ + } + } catch (e: any) { + if (e?.response?.status === 404) { + setError(t('slideshow.notFound', 'This slideshow link is not active.')); + } else { + setError(t('slideshow.startFailed', 'Could not start the slideshow. Please try again.')); + } + } + }, [slug, token, preloadDecode, t]); + + // ----- Auto-advance timer (restarts when interval or photo count changes). ----- + useEffect(() => { + if (phase !== 'running') return; + if (photos.length < 2) return; + const id = window.setInterval(advance, Math.max(1000, settings.interval_ms)); + return () => window.clearInterval(id); + }, [phase, photos.length, settings.interval_ms, advance]); + + // ----- Live poll: settings changes + new uploads + link death. ----- + useEffect(() => { + if (phase !== 'running') return; + let cancelled = false; + const id = window.setInterval(async () => { + try { + const state = await slideshowService.getState(slug, token); + if (cancelled) return; + const prev = settingsRef.current; + const next: SlideshowSettings = { + interval_ms: state.interval_ms, + transition: state.transition, + transition_ms: state.transition_ms, + colorfilter: state.colorfilter, + watermark: state.watermark, + }; + if (JSON.stringify(next) !== JSON.stringify({ + interval_ms: prev.interval_ms, + transition: prev.transition, + transition_ms: prev.transition_ms, + colorfilter: prev.colorfilter, + watermark: prev.watermark, + })) { + setSettings(next); + } + if (state.photo_count !== photosRef.current.length) { + await loadPhotos(); + } + } catch (e: any) { + if (cancelled) return; + if (e?.response?.status === 404) { + // Admin disabled/rotated the link, or the gallery expired. + setPhase('ended'); + } + // Other errors (transient network): ignore and keep showing. + } + }, STATE_POLL_MS); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [phase, slug, token, loadPhotos]); + + // ----- Decode-ahead the next 1–2 images so transitions don't pop. ----- + useEffect(() => { + if (phase !== 'running' || photos.length === 0) return; + for (let i = 1; i <= 2; i++) { + void preloadDecode(photos[(positionRef.current + i) % photos.length]); + } + }, [phase, photos, buffers, preloadDecode]); + + // ----- Auto-hide the cursor after inactivity. ----- + useEffect(() => { + if (phase !== 'running') return; + let timer: number; + const onMove = () => { + setCursorHidden(false); + window.clearTimeout(timer); + timer = window.setTimeout(() => setCursorHidden(true), 3000); + }; + onMove(); + window.addEventListener('mousemove', onMove); + return () => { + window.removeEventListener('mousemove', onMove); + window.clearTimeout(timer); + }; + }, [phase]); + + const isDip = settings.transition === 'dipwhite' || settings.transition === 'dipblack'; + // Dip & cut swap instantly under the flash; the others crossfade. + const bufferMs = settings.transition === 'cut' || isDip ? 0 : Math.max(0, settings.transition_ms); + + const bufferStyle = (buf: 0 | 1): React.CSSProperties => { + const isActive = active === buf; + const base: React.CSSProperties = { + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + opacity: isActive ? 1 : 0, + transition: `opacity ${bufferMs}ms ease-in-out, transform ${bufferMs}ms ease-in-out`, + }; + if (settings.transition === 'slide') { + base.transform = isActive ? 'translateX(0)' : 'translateX(6%)'; + } + return base; + }; + + const imgStyle = (buf: 0 | 1): React.CSSProperties => { + const isActive = active === buf; + const style: React.CSSProperties = { + maxWidth: '100%', + maxHeight: '100%', + objectFit: 'contain', + filter: imageFilter(settings.colorfilter), + }; + if (settings.transition === 'kenburns' && isActive) { + style.animation = `picpeak-kenburns ${Math.max(2000, settings.interval_ms)}ms ease-out forwards`; + } + return style; + }; + + const renderBuffer = (buf: 0 | 1) => { + const idx = buffers[buf]; + const photo = photos[idx]; + if (!photo) return null; + return ( +
+ +
+ ); + }; + + const overlay = colorOverlayStyle(settings.colorfilter); + const watermarkUrl = settings.watermark ? buildResourceUrl(settings.watermark.url) : null; + + const containerStyle: React.CSSProperties = useMemo( + () => ({ + position: 'fixed', + inset: 0, + background: '#000', + overflow: 'hidden', + cursor: cursorHidden ? 'none' : 'default', + userSelect: 'none', + }), + [cursorHidden] + ); + + return ( +
+ + + {phase === 'splash' && ( +
+
+
+ {t('slideshow.start', 'Start slideshow')} +
+ {eventName &&
{eventName}
} + {error &&
{error}
} +
+ )} + + {phase === 'running' && photos.length === 0 && ( +
+ {t('slideshow.waiting', 'Waiting for photos…')} +
+ )} + + {phase === 'running' && photos.length > 0 && ( + <> + {renderBuffer(0)} + {renderBuffer(1)} + + {/* Tint / vignette overlay */} + {overlay &&
} + + {/* Dip-to-white / dip-to-black flash (re-keyed each advance) */} + {isDip && flash > 0 && ( +
+ )} + + {/* ZDF/ARD-ident-style watermark: white, semi-transparent corner logo */} + {watermarkUrl && ( + + )} + + )} + + {phase === 'ended' && ( +
+ {t('slideshow.ended', 'Slideshow ended.')} +
+ )} +
+ ); +} + +export default SlideshowPage; diff --git a/frontend/src/services/slideshow.service.ts b/frontend/src/services/slideshow.service.ts new file mode 100644 index 00000000..4562699c --- /dev/null +++ b/frontend/src/services/slideshow.service.ts @@ -0,0 +1,109 @@ +import { api } from '../config/api'; + +export type SlideshowTransition = 'crossfade' | 'cut' | 'slide' | 'kenburns' | 'dipwhite' | 'dipblack'; +export type SlideshowColorFilter = 'none' | 'bw' | 'sepia' | 'warm' | 'cool' | 'vignette'; +// Which logo the watermark overlays. The first three are branding assets the +// admin uploads in Settings → Branding; 'event' is the event's own hero logo. +export type SlideshowWatermarkSource = 'logo' | 'logo_dark' | 'favicon' | 'event'; +export type SlideshowWatermarkPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +// 'white' recolors the logo white (TV-ident look, for dark/transparent marks); +// 'original' keeps the logo's own colors (for boxed/colored badges). +export type SlideshowWatermarkStyle = 'white' | 'original'; +export const SLIDESHOW_WATERMARK_STYLES: SlideshowWatermarkStyle[] = ['white', 'original']; +// Per-surface watermark choice. 'inherit' = follow the global default +// (admin Settings → Slideshow); 'on'/'off' = explicit override. +export type SlideshowWatermarkMode = 'inherit' | 'on' | 'off'; +export const SLIDESHOW_WATERMARK_MODES: SlideshowWatermarkMode[] = ['inherit', 'on', 'off']; + +export const SLIDESHOW_TRANSITIONS: SlideshowTransition[] = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack']; +export const SLIDESHOW_COLORFILTERS: SlideshowColorFilter[] = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette']; +export const SLIDESHOW_WATERMARK_SOURCES: SlideshowWatermarkSource[] = ['logo', 'logo_dark', 'favicon', 'event']; +export const SLIDESHOW_WATERMARK_POSITIONS: SlideshowWatermarkPosition[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; + +// Canonical editable style shape, shared by the per-event card and the +// per-event-type preset editor. The per-event-type `slideshow_preset` JSON +// uses exactly these keys; the per-event API maps them to `show_*` columns. +export interface SlideshowStyle { + interval_ms: number; + transition: SlideshowTransition; + transition_ms: number; + watermark: SlideshowWatermarkMode; + watermark_source: SlideshowWatermarkSource; + watermark_position: SlideshowWatermarkPosition; + watermark_opacity: number; + watermark_style: SlideshowWatermarkStyle; + colorfilter: SlideshowColorFilter; +} + +export const DEFAULT_SLIDESHOW_STYLE: SlideshowStyle = { + interval_ms: 5000, + transition: 'crossfade', + transition_ms: 800, + watermark: 'inherit', + watermark_source: 'logo', + watermark_position: 'bottom-right', + watermark_opacity: 60, + watermark_style: 'white', + colorfilter: 'none', +}; + +// Global slideshow defaults (admin Settings → Slideshow). The per-event +// watermark mode 'inherit' falls back to these. +export interface SlideshowGlobalDefaults { + slideshow_watermark_enabled: boolean; + slideshow_watermark_source: SlideshowWatermarkSource; + slideshow_watermark_position: SlideshowWatermarkPosition; + slideshow_watermark_opacity: number; + slideshow_watermark_style: SlideshowWatermarkStyle; +} + +// Resolved watermark the kiosk renders (logo URL already resolved server-side). +export interface SlideshowWatermark { + url: string; + position: SlideshowWatermarkPosition; + opacity: number; + style: SlideshowWatermarkStyle; +} + +export interface SlideshowSettings { + interval_ms: number; + transition: SlideshowTransition; + transition_ms: number; + colorfilter: SlideshowColorFilter; + watermark: SlideshowWatermark | null; +} + +export interface SlideshowSession { + token: string; + event: { + event_name: string; + event_type?: string; + color_theme?: string | null; + }; + settings: SlideshowSettings; + photo_count: number; + expires_at: string | null; +} + +// Tiny live-poll payload (settings + current photo count). Hit every few +// seconds by the running show so admin changes and new uploads take effect. +export interface SlideshowState extends SlideshowSettings { + photo_count: number; + expires_at: string | null; +} + +export const slideshowService = { + // Open a slideshow session: validates the share token, sets the gallery + // auth cookie (so requests are authorized) and returns the minted + // session token + current settings/count. Throws 404 if the link is + // disabled, rotated, or the gallery isn't live. + async getSession(slug: string, token: string): Promise { + const response = await api.get(`/gallery/${slug}/show/${token}/session`); + return response.data; + }, + + async getState(slug: string, token: string): Promise { + const response = await api.get(`/gallery/${slug}/show/${token}/state`); + return response.data; + }, +};