diff --git a/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx b/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx new file mode 100644 index 00000000..c6b3b903 --- /dev/null +++ b/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx @@ -0,0 +1,157 @@ +/** + * + * + * Global default for the Live Slideshow watermark (the white, semi-transparent + * corner logo). Every event whose watermark mode is "Use global default" + * (events.show_watermark = NULL) follows this; events can still override on/off + * per event. Persisted via PUT /admin/settings/slideshow (app_settings, + * type 'slideshow'). + */ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { MonitorPlay, Save } from 'lucide-react'; +import { Button, Card } from '../common'; +import { settingsService } from '../../services/settings.service'; +import { + SLIDESHOW_WATERMARK_POSITIONS, + SLIDESHOW_WATERMARK_STYLES, + type SlideshowGlobalDefaults, +} from '../../services/slideshow.service'; +import { WatermarkSourcePicker } from './WatermarkSourcePicker'; + +const DEFAULTS: SlideshowGlobalDefaults = { + slideshow_watermark_enabled: false, + slideshow_watermark_source: 'logo', + slideshow_watermark_position: 'bottom-right', + slideshow_watermark_opacity: 60, + slideshow_watermark_style: 'white', +}; + +const inputClass = + 'w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm'; +const labelClass = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1'; + +export const SlideshowGlobalDefaultsCard: React.FC = () => { + const { t } = useTranslation(); + const [val, setVal] = useState(DEFAULTS); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + settingsService.getSettingsByType('slideshow').then((s) => { + if (cancelled || !s) return; + setVal({ + slideshow_watermark_enabled: s.slideshow_watermark_enabled ?? DEFAULTS.slideshow_watermark_enabled, + slideshow_watermark_source: s.slideshow_watermark_source ?? DEFAULTS.slideshow_watermark_source, + slideshow_watermark_position: s.slideshow_watermark_position ?? DEFAULTS.slideshow_watermark_position, + slideshow_watermark_opacity: s.slideshow_watermark_opacity ?? DEFAULTS.slideshow_watermark_opacity, + slideshow_watermark_style: s.slideshow_watermark_style ?? DEFAULTS.slideshow_watermark_style, + }); + }).catch(() => { /* keep defaults */ }); + return () => { cancelled = true; }; + }, []); + + const save = async () => { + setSaving(true); + try { + await settingsService.updateSlideshowDefaults(val); + toast.success(t('slideshow.defaultsSaved', 'Slideshow defaults saved')); + } catch { + toast.error(t('common.error', 'Something went wrong')); + } finally { + setSaving(false); + } + }; + + return ( + +

+ + {t('slideshow.globalTitle', 'Global slideshow watermark')} +

+

+ {t('slideshow.globalDescription', 'Default logo watermark for every slideshow. Individual events can override this on or off.')} +

+ +
+ + + {val.slideshow_watermark_enabled && ( +
+
+ + setVal({ ...val, slideshow_watermark_source: s })} + /> +
+
+
+ + +
+
+ + setVal({ ...val, slideshow_watermark_opacity: Math.min(100, Math.max(0, parseInt(e.target.value, 10) || 0)) })} + className={inputClass} + /> +
+
+ + +
+
+
+ )} + + +
+
+ ); +}; + +export default SlideshowGlobalDefaultsCard; diff --git a/frontend/src/components/admin/SlideshowSettingsCard.tsx b/frontend/src/components/admin/SlideshowSettingsCard.tsx new file mode 100644 index 00000000..9abc3131 --- /dev/null +++ b/frontend/src/components/admin/SlideshowSettingsCard.tsx @@ -0,0 +1,239 @@ +/** + * + * + * Per-event "Live Slideshow" ("Diashow") admin surface (migrations 137/138). + * A token-only fullscreen kiosk link for live events that auto-picks-up new + * uploads while it runs. Mounted once on the EventDetailsPage; admin can: + * - Generate the slideshow link on demand (mints show_share_token) + * - Copy / Regenerate (rotate, kills the old link) / Disable it + * - Tune the LIVE style (transition, timing, color filter, logo watermark) + * via the shared ; a running projector picks the + * changes up within a few seconds via the show page's settings poll. + * + * New events inherit their initial style from the event TYPE preset; this + * card edits the per-event override. Settings save through + * PATCH /api/admin/events/:id/slideshow; link actions through + * POST .../slideshow/{generate,disable}. + */ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { MonitorPlay, Copy, CheckCircle, RotateCw, Trash2, Save } from 'lucide-react'; +import { Button, Card } from '../common'; +import { eventsService } from '../../services/events.service'; +import { DEFAULT_SLIDESHOW_STYLE, type SlideshowStyle } from '../../services/slideshow.service'; +import { SlideshowStyleFields } from './SlideshowStyleFields'; + +export interface SlideshowSettingsCardProps { + eventId: number; + slug: string; + isArchived?: boolean; + /** The event's own hero logo, previewed for the 'event' watermark source. */ + eventLogoUrl?: string | null; + initial: { + show_share_token?: string | null; + show_interval_ms?: number; + show_transition?: string; + show_transition_ms?: number; + show_watermark?: boolean | null; + show_watermark_source?: string; + show_watermark_position?: string; + show_watermark_opacity?: number; + show_watermark_style?: string; + show_colorfilter?: string; + }; + onChanged?: () => void; +} + +// Tri-state: null/undefined → inherit the global default; true → on; false → off. +function watermarkMode(v: boolean | null | undefined): SlideshowStyle['watermark'] { + if (v === null || v === undefined) return 'inherit'; + return v ? 'on' : 'off'; +} + +function styleFromInitial(initial: SlideshowSettingsCardProps['initial']): SlideshowStyle { + return { + interval_ms: initial.show_interval_ms ?? DEFAULT_SLIDESHOW_STYLE.interval_ms, + transition: (initial.show_transition as SlideshowStyle['transition']) ?? DEFAULT_SLIDESHOW_STYLE.transition, + transition_ms: initial.show_transition_ms ?? DEFAULT_SLIDESHOW_STYLE.transition_ms, + watermark: watermarkMode(initial.show_watermark), + watermark_source: (initial.show_watermark_source as SlideshowStyle['watermark_source']) ?? DEFAULT_SLIDESHOW_STYLE.watermark_source, + watermark_position: (initial.show_watermark_position as SlideshowStyle['watermark_position']) ?? DEFAULT_SLIDESHOW_STYLE.watermark_position, + watermark_opacity: initial.show_watermark_opacity ?? DEFAULT_SLIDESHOW_STYLE.watermark_opacity, + watermark_style: (initial.show_watermark_style as SlideshowStyle['watermark_style']) ?? DEFAULT_SLIDESHOW_STYLE.watermark_style, + colorfilter: (initial.show_colorfilter as SlideshowStyle['colorfilter']) ?? DEFAULT_SLIDESHOW_STYLE.colorfilter, + }; +} + +export const SlideshowSettingsCard: React.FC = ({ + eventId, slug, isArchived, eventLogoUrl, initial, onChanged, +}) => { + const { t } = useTranslation(); + + const [token, setToken] = useState(initial.show_share_token ?? null); + const [style, setStyle] = useState(() => styleFromInitial(initial)); + const [copied, setCopied] = useState(false); + const [busy, setBusy] = useState(false); + const [saving, setSaving] = useState(false); + + const link = token ? `${window.location.origin}/gallery/${slug}/show/${token}` : ''; + + const generate = async () => { + setBusy(true); + try { + const res = await eventsService.generateSlideshowLink(eventId); + setToken(res.show_share_token); + toast.success(t('slideshow.linkGenerated', 'Slideshow link generated')); + onChanged?.(); + } catch { + toast.error(t('common.error', 'Something went wrong')); + } finally { + setBusy(false); + } + }; + + const disable = async () => { + if (!confirm(t('slideshow.disableConfirm', 'Disable this slideshow link? The current link will stop working.'))) { + return; + } + setBusy(true); + try { + await eventsService.disableSlideshowLink(eventId); + setToken(null); + toast.success(t('slideshow.linkDisabled', 'Slideshow link disabled')); + onChanged?.(); + } catch { + toast.error(t('common.error', 'Something went wrong')); + } finally { + setBusy(false); + } + }; + + const copy = async () => { + try { + await navigator.clipboard.writeText(link); + } catch { + const ta = document.createElement('textarea'); + ta.value = link; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const saveSettings = async () => { + setSaving(true); + try { + await eventsService.updateSlideshowSettings(eventId, { + show_interval_ms: style.interval_ms, + show_transition: style.transition, + show_transition_ms: style.transition_ms, + // Tri-state → null (inherit global) / true / false. + show_watermark: style.watermark === 'inherit' ? null : style.watermark === 'on', + show_watermark_source: style.watermark_source, + show_watermark_position: style.watermark_position, + show_watermark_opacity: style.watermark_opacity, + show_watermark_style: style.watermark_style, + show_colorfilter: style.colorfilter, + }); + toast.success(t('slideshow.settingsSaved', 'Slideshow settings saved')); + onChanged?.(); + } catch { + toast.error(t('common.error', 'Something went wrong')); + } finally { + setSaving(false); + } + }; + + const inputClass = + 'w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm'; + const labelClass = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1'; + + return ( + +

+ + {t('slideshow.adminTitle', 'Live Slideshow')} +

+

+ {t('slideshow.adminDescription', 'A separate fullscreen link for projectors at live events. It shows all published photos and automatically picks up new uploads while running.')} +

+ +
+ {!token ? ( + + ) : ( + <> +
+ +
+ + +
+
+ + +
+
+ + {/* Live style settings */} +
+ +
+

+ {t('slideshow.liveHint', 'Changes apply to a running slideshow within a few seconds — no need to regenerate the link.')} +

+ + + )} +
+
+ ); +}; + +export default SlideshowSettingsCard; diff --git a/frontend/src/components/admin/SlideshowStyleFields.tsx b/frontend/src/components/admin/SlideshowStyleFields.tsx new file mode 100644 index 00000000..8132ed93 --- /dev/null +++ b/frontend/src/components/admin/SlideshowStyleFields.tsx @@ -0,0 +1,177 @@ +/** + * + * + * Shared, controlled editor for a slideshow's visual style — transition, + * timing, watermark and color filter. Used in two places: + * - SlideshowSettingsCard (per-event live settings) + * - EventTypeModal (per-event-type preset that new events inherit) + * + * Purely presentational: it owns no persistence, just renders the controls + * for a SlideshowStyle value and calls onChange with the next value. + */ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + SLIDESHOW_TRANSITIONS, + SLIDESHOW_COLORFILTERS, + SLIDESHOW_WATERMARK_POSITIONS, + SLIDESHOW_WATERMARK_MODES, + SLIDESHOW_WATERMARK_STYLES, + type SlideshowStyle, +} from '../../services/slideshow.service'; +import { WatermarkSourcePicker } from './WatermarkSourcePicker'; + +export interface SlideshowStyleFieldsProps { + value: SlideshowStyle; + onChange: (next: SlideshowStyle) => void; + /** Per-event hero logo, previewed for the 'event' watermark source. */ + eventLogoUrl?: string | null; +} + +const inputClass = + 'w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm'; +const labelClass = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1'; + +const titleCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + +export const SlideshowStyleFields: React.FC = ({ value, onChange, eventLogoUrl }) => { + const { t } = useTranslation(); + const set = (patch: Partial) => onChange({ ...value, ...patch }); + + return ( +
+ {/* Transition + timing */} +
+
+ + +
+
+ + set({ interval_ms: Math.min(120, Math.max(1, parseInt(e.target.value, 10) || 5)) * 1000 })} + className={inputClass} + /> +
+
+ + set({ transition_ms: Math.min(5000, Math.max(100, parseInt(e.target.value, 10) || 800)) })} + className={inputClass} + /> +
+
+ + {/* Color filter */} +
+ + +
+ + {/* Watermark */} +
+ + +

+ {t('slideshow.watermarkDescription', 'Overlay a white, semi-transparent logo in a corner (like a TV station ident).')} +

+ + {value.watermark === 'on' && ( +
+
+ + set({ watermark_source: s })} + eventLogoUrl={eventLogoUrl} + /> +
+
+
+ + +
+
+ + set({ watermark_opacity: Math.min(100, Math.max(0, parseInt(e.target.value, 10) || 0)) })} + className={inputClass} + /> +
+
+ + +
+
+
+ )} +
+
+ ); +}; + +export default SlideshowStyleFields; diff --git a/frontend/src/components/admin/WatermarkSourcePicker.tsx b/frontend/src/components/admin/WatermarkSourcePicker.tsx new file mode 100644 index 00000000..95774a8d --- /dev/null +++ b/frontend/src/components/admin/WatermarkSourcePicker.tsx @@ -0,0 +1,78 @@ +/** + * + * + * Visible picker for the slideshow watermark logo. Instead of a blind dropdown + * the admin sees each branding asset (light logo, dark-mode logo, favicon) and + * the event's own logo, and clicks the one to overlay. Previews render on a + * transparency checkerboard so both light and dark marks are visible. + * + * URLs come from the public settings (branding assets) + an optional per-event + * logo. A source with no configured logo still selects, but shows a "not set" + * placeholder so the admin knows to upload one. + */ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { usePublicSettings } from '../../hooks/usePublicSettings'; +import { buildResourceUrl } from '../../utils/url'; +import type { SlideshowWatermarkSource } from '../../services/slideshow.service'; + +export interface WatermarkSourcePickerProps { + value: SlideshowWatermarkSource; + onChange: (s: SlideshowWatermarkSource) => void; + /** Per-event hero logo, previewed for the 'event' source when available. */ + eventLogoUrl?: string | null; +} + +// Classic transparency checkerboard so white and dark logos both show up. +const CHECKER: React.CSSProperties = { + backgroundImage: + 'linear-gradient(45deg, #c8c8c8 25%, transparent 25%), linear-gradient(-45deg, #c8c8c8 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #c8c8c8 75%), linear-gradient(-45deg, transparent 75%, #c8c8c8 75%)', + backgroundSize: '12px 12px', + backgroundPosition: '0 0, 0 6px, 6px -6px, -6px 0', + backgroundColor: '#f0f0f0', +}; + +export const WatermarkSourcePicker: React.FC = ({ value, onChange, eventLogoUrl }) => { + const { t } = useTranslation(); + const { data: ps } = usePublicSettings(); + + const options: Array<{ key: SlideshowWatermarkSource; label: string; url?: string | null }> = [ + { key: 'logo', label: t('slideshow.watermarkSource.logo', 'Light logo'), url: ps?.branding_logo_url }, + { key: 'logo_dark', label: t('slideshow.watermarkSource.logo_dark', 'Dark-mode logo'), url: ps?.branding_logo_url_dark }, + { key: 'favicon', label: t('slideshow.watermarkSource.favicon', 'Favicon'), url: ps?.branding_favicon_url }, + { key: 'event', label: t('slideshow.watermarkSource.event', 'Event logo'), url: eventLogoUrl }, + ]; + + return ( +
+ {options.map((opt) => { + const selected = value === opt.key; + const resolved = opt.url ? buildResourceUrl(opt.url) : null; + return ( + + ); + })} +
+ ); +}; + +export default WatermarkSourcePicker; diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index a413ecdb..e5492baf 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -62,6 +62,7 @@ import { Button, Input, Card, Loading, MarkdownContent, LocalizedDateInput } fro import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, PublishGalleryDialog, DuplicateEventDialog, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin'; import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker'; import { EventReminderOverrideCard } from '../../components/admin/EventReminderOverrideCard'; +import { SlideshowSettingsCard } from '../../components/admin/SlideshowSettingsCard'; import { useFeatureFlags } from '../../contexts/FeatureFlagsContext'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; @@ -2158,6 +2159,27 @@ export const EventDetailsPage: React.FC = () => { + {/* Live Slideshow ("Diashow") link + live display settings (migration 137) */} + refetchEvent()} + /> + {/* Pre-event reminder override (migration 143). Hidden when the reminderEmails master flag is off — the override here would never fire since the cron itself no-ops. */} diff --git a/frontend/src/pages/admin/EventTypesPage.tsx b/frontend/src/pages/admin/EventTypesPage.tsx index 800e4841..8ee65b5d 100644 --- a/frontend/src/pages/admin/EventTypesPage.tsx +++ b/frontend/src/pages/admin/EventTypesPage.tsx @@ -18,6 +18,21 @@ import { import { Button, Input, Card, Loading } from '../../components/common'; import { eventTypesService, EventType, CreateEventTypeData, UpdateEventTypeData } from '../../services/eventTypes.service'; import { GALLERY_THEME_PRESETS } from '../../types/theme.types'; +import { SlideshowStyleFields } from '../../components/admin/SlideshowStyleFields'; +import { SlideshowGlobalDefaultsCard } from '../../components/admin/SlideshowGlobalDefaultsCard'; +import { DEFAULT_SLIDESHOW_STYLE, type SlideshowStyle } from '../../services/slideshow.service'; + +// Parse a type's stored slideshow_preset (JSON string | object | null) into a +// full SlideshowStyle, falling back to defaults for any missing keys. +function parseSlideshowPreset(raw: EventType['slideshow_preset']): SlideshowStyle { + if (!raw) return { ...DEFAULT_SLIDESHOW_STYLE }; + try { + const obj = typeof raw === 'string' ? JSON.parse(raw) : raw; + return { ...DEFAULT_SLIDESHOW_STYLE, ...obj }; + } catch { + return { ...DEFAULT_SLIDESHOW_STYLE }; + } +} // Common emoji options for event types const EMOJI_OPTIONS = [ @@ -137,6 +152,9 @@ export const EventTypesPage: React.FC = () => { + {/* Global Live Slideshow watermark default (per-event/type can override) */} + + {/* Filters */}
@@ -329,6 +347,12 @@ const EventTypeModal: React.FC = ({ theme_preset: eventType?.theme_preset || 'default' }); + // Slideshow preset new events of this type inherit. Edited via the shared + // ; serialized into slideshow_preset on submit. + const [slideshowStyle, setSlideshowStyle] = useState( + () => parseSlideshowPreset(eventType?.slideshow_preset) + ); + const [errors, setErrors] = useState>>({}); const handleSubmit = (e: React.FormEvent) => { @@ -357,15 +381,17 @@ const EventTypeModal: React.FC = ({ if (form.slug_prefix !== eventType?.slug_prefix) updates.slug_prefix = form.slug_prefix; if (form.emoji !== eventType?.emoji) updates.emoji = form.emoji; if (form.theme_preset !== eventType?.theme_preset) updates.theme_preset = form.theme_preset; + const originalStyle = JSON.stringify(parseSlideshowPreset(eventType?.slideshow_preset)); + if (JSON.stringify(slideshowStyle) !== originalStyle) updates.slideshow_preset = slideshowStyle; onSubmit(updates); } else { - onSubmit(form); + onSubmit({ ...form, slideshow_preset: slideshowStyle }); } }; return (
- +

@@ -460,6 +486,19 @@ const EventTypeModal: React.FC = ({

+ {/* Live Slideshow preset (migration 138). New events of this type + inherit these slideshow defaults; admins can still override + per event on the event detail page. */} +
+ +

+ {t('eventTypes.form.slideshowPresetHint', 'Default slideshow style for new events of this type.')} +

+ +
+ {/* Active toggle for editing */} {isEditing && (