feat(slideshow): public fullscreen slideshow viewer
- /gallery/:slug/show/:token route + SlideshowPage: splash -> fullscreen kiosk, crossfade/cut/slide/kenburns/dip-to-white/dip-to-black transitions, color filters, white/original logo watermark overlay, contain/letterbox, cursor auto-hide, quiet-append of new uploads, live settings poll, and decode-ahead preload (first slide decoded before playback) so transitions do not struggle. - slideshow.service for session/state + shared style types.
This commit is contained in:
@@ -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() {
|
||||
<Routes>
|
||||
{/* Public gallery routes */}
|
||||
<Route path="/gallery/preview" element={<PreviewPage />} />
|
||||
{/* Live Slideshow ("Diashow") — token-only fullscreen kiosk.
|
||||
Self-manages its session token; no GalleryAuthProvider. */}
|
||||
<Route path="/gallery/:slug/show/:token" element={
|
||||
<Suspense fallback={<Loading />}>
|
||||
<SlideshowPage />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/gallery/:slug/client-access" element={
|
||||
<GalleryAuthProvider>
|
||||
<ClientAccessPage />
|
||||
|
||||
@@ -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<Phase>('splash');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [eventName, setEventName] = useState<string>('');
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
const [settings, setSettings] = useState<SlideshowSettings>(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<Photo[]>(photos);
|
||||
const settingsRef = useRef<SlideshowSettings>(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 <img> 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 <img> 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 (
|
||||
<div style={bufferStyle(buf)}>
|
||||
<img src={photoSrc(photo)} alt="" style={imgStyle(buf)} draggable={false} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div style={containerStyle}>
|
||||
<style>{`
|
||||
@keyframes picpeak-kenburns {
|
||||
0% { transform: scale(1) translate(0, 0); }
|
||||
100% { transform: scale(1.12) translate(-2%, -2%); }
|
||||
}
|
||||
@keyframes picpeak-dip {
|
||||
0% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{phase === 'splash' && (
|
||||
<div
|
||||
onClick={start}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 64, lineHeight: 1, marginBottom: 24 }}>▶</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 600 }}>
|
||||
{t('slideshow.start', 'Start slideshow')}
|
||||
</div>
|
||||
{eventName && <div style={{ fontSize: 16, opacity: 0.7, marginTop: 8 }}>{eventName}</div>}
|
||||
{error && <div style={{ color: '#f87171', marginTop: 24, fontSize: 16 }}>{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'running' && photos.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
opacity: 0.6,
|
||||
fontSize: 20,
|
||||
}}
|
||||
>
|
||||
{t('slideshow.waiting', 'Waiting for photos…')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'running' && photos.length > 0 && (
|
||||
<>
|
||||
{renderBuffer(0)}
|
||||
{renderBuffer(1)}
|
||||
|
||||
{/* Tint / vignette overlay */}
|
||||
{overlay && <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', ...overlay }} />}
|
||||
|
||||
{/* Dip-to-white / dip-to-black flash (re-keyed each advance) */}
|
||||
{isDip && flash > 0 && (
|
||||
<div
|
||||
key={flash}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
background: settings.transition === 'dipwhite' ? '#fff' : '#000',
|
||||
animation: `picpeak-dip ${Math.max(200, settings.transition_ms)}ms ease-in-out`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ZDF/ARD-ident-style watermark: white, semi-transparent corner logo */}
|
||||
{watermarkUrl && (
|
||||
<img
|
||||
src={watermarkUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
...watermarkCorner(settings.watermark!.position),
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
maxWidth: '16vw',
|
||||
maxHeight: '14vh',
|
||||
opacity: Math.min(1, Math.max(0, (settings.watermark!.opacity ?? 60) / 100)),
|
||||
// 'white' recolors the logo white (dark/transparent marks); 'original'
|
||||
// leaves a boxed/colored logo as-is so it doesn't become a white blob.
|
||||
filter: settings.watermark!.style === 'original' ? 'none' : 'brightness(0) invert(1)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'ended' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
opacity: 0.7,
|
||||
fontSize: 20,
|
||||
}}
|
||||
>
|
||||
{t('slideshow.ended', 'Slideshow ended.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlideshowPage;
|
||||
@@ -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 <img> 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<SlideshowSession> {
|
||||
const response = await api.get<SlideshowSession>(`/gallery/${slug}/show/${token}/session`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getState(slug: string, token: string): Promise<SlideshowState> {
|
||||
const response = await api.get<SlideshowState>(`/gallery/${slug}/show/${token}/state`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user