From 7bbce79394b191a135a01b977d1b1e4fdf1d67d6 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 5 Jan 2026 15:02:21 +0100 Subject: [PATCH] Add Live Show settings in admin app --- .beads/last-touched | 2 +- .../Controllers/Api/LiveShowController.php | 1 + .../Requests/Tenant/EventStoreRequest.php | 16 + resources/js/admin/api.ts | 14 + resources/js/admin/constants.ts | 2 + .../js/admin/i18n/locales/de/management.json | 79 +++ .../js/admin/i18n/locales/en/management.json | 79 +++ resources/js/admin/mobile/EventDetailPage.tsx | 20 +- .../mobile/EventLiveShowSettingsPage.tsx | 519 ++++++++++++++++++ resources/js/admin/router.tsx | 2 + tests/Feature/EventControllerTest.php | 44 ++ 11 files changed, 771 insertions(+), 7 deletions(-) create mode 100644 resources/js/admin/mobile/EventLiveShowSettingsPage.tsx diff --git a/.beads/last-touched b/.beads/last-touched index 004ff10..c92d25c 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-xg5 +fotospiel-app-1we diff --git a/app/Http/Controllers/Api/LiveShowController.php b/app/Http/Controllers/Api/LiveShowController.php index 0b5c12c..c220319 100644 --- a/app/Http/Controllers/Api/LiveShowController.php +++ b/app/Http/Controllers/Api/LiveShowController.php @@ -222,6 +222,7 @@ class LiveShowController extends BaseController return array_merge([ 'retention_window_hours' => self::DEFAULT_RETENTION_HOURS, + 'moderation_mode' => 'manual', 'playback_mode' => 'newest_first', 'pace_mode' => 'auto', 'fixed_interval_seconds' => 8, diff --git a/app/Http/Requests/Tenant/EventStoreRequest.php b/app/Http/Requests/Tenant/EventStoreRequest.php index f236bf7..deb5009 100644 --- a/app/Http/Requests/Tenant/EventStoreRequest.php +++ b/app/Http/Requests/Tenant/EventStoreRequest.php @@ -46,6 +46,22 @@ class EventStoreRequest extends FormRequest 'settings.branding.*' => ['nullable'], 'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])], 'settings.guest_upload_visibility' => ['nullable', Rule::in(['review', 'immediate'])], + 'settings.live_show' => ['nullable', 'array'], + 'settings.live_show.moderation_mode' => ['nullable', Rule::in(['off', 'manual', 'trusted_only'])], + 'settings.live_show.retention_window_hours' => ['nullable', 'integer', 'min:1', 'max:72'], + 'settings.live_show.playback_mode' => ['nullable', Rule::in(['newest_first', 'balanced', 'curated'])], + 'settings.live_show.pace_mode' => ['nullable', Rule::in(['auto', 'fixed'])], + 'settings.live_show.fixed_interval_seconds' => ['nullable', 'integer', 'min:3', 'max:20'], + 'settings.live_show.layout_mode' => ['nullable', Rule::in(['single', 'split', 'grid_burst'])], + 'settings.live_show.effect_preset' => ['nullable', Rule::in([ + 'film_cut', + 'shutter_flash', + 'polaroid_toss', + 'parallax_glide', + 'light_effects', + ])], + 'settings.live_show.effect_intensity' => ['nullable', 'integer', 'min:0', 'max:100'], + 'settings.live_show.background_mode' => ['nullable', Rule::in(['blur_last', 'gradient', 'solid', 'brand'])], 'settings.watermark' => ['nullable', 'array'], 'settings.watermark.mode' => ['nullable', Rule::in(['base', 'custom', 'off'])], 'settings.watermark.asset' => ['nullable', 'string', 'max:500'], diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 54d41de..1ddf316 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -60,6 +60,18 @@ export type TenantEventType = { updated_at?: string | null; }; +export type LiveShowSettings = { + moderation_mode?: 'off' | 'manual' | 'trusted_only'; + retention_window_hours?: number; + playback_mode?: 'newest_first' | 'balanced' | 'curated'; + pace_mode?: 'auto' | 'fixed'; + fixed_interval_seconds?: number; + layout_mode?: 'single' | 'split' | 'grid_burst'; + effect_preset?: 'film_cut' | 'shutter_flash' | 'polaroid_toss' | 'parallax_glide' | 'light_effects'; + effect_intensity?: number; + background_mode?: 'blur_last' | 'gradient' | 'solid' | 'brand'; +}; + export type TenantEvent = { id: number; name: string | Record; @@ -80,6 +92,7 @@ export type TenantEvent = { settings?: Record & { engagement_mode?: 'tasks' | 'photo_only'; guest_upload_visibility?: 'review' | 'immediate'; + live_show?: LiveShowSettings; watermark?: WatermarkSettings; watermark_allowed?: boolean | null; watermark_serve_originals?: boolean | null; @@ -717,6 +730,7 @@ type EventSavePayload = { package_id?: number; accepted_waiver?: boolean; settings?: Record & { + live_show?: LiveShowSettings; watermark?: WatermarkSettings; watermark_serve_originals?: boolean | null; }; diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index 15d2017..e0ade0d 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -34,3 +34,5 @@ export const ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH = (slug: string): string => export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}`); export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/branding`); export const ADMIN_EVENT_LIVE_SHOW_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/live-show`); +export const ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH = (slug: string): string => + adminPath(`/mobile/events/${encodeURIComponent(slug)}/live-show/settings`); diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 8fa5aad..44aeb0b 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -311,6 +311,7 @@ "qr": "QR-Code-Layouts", "images": "Bildverwaltung", "liveShow": "Live-Show-Warteschlange", + "liveShowSettings": "Live-Show Einstellungen", "guests": "Gästeverwaltung", "guestMessages": "Gästebenachrichtigungen", "branding": "Branding & Design", @@ -2179,6 +2180,84 @@ "notEligible": "Nicht zulässig", "actionFailed": "Live-Show-Aktion fehlgeschlagen." }, + "liveShowSettings": { + "title": "Live-Show Einstellungen", + "subtitle": "Tempo, Layout und Effekte für die Leinwand feinjustieren.", + "loadFailed": "Live-Show-Einstellungen konnten nicht geladen werden.", + "save": "Einstellungen speichern", + "saveSuccess": "Live-Show-Einstellungen gespeichert.", + "saveFailed": "Live-Show-Einstellungen konnten nicht gespeichert werden.", + "sections": { + "moderation": "Moderation", + "playback": "Wiedergabe", + "effects": "Effekte & Layout" + }, + "fields": { + "moderationMode": "Moderationsmodus", + "retention": "Aufbewahrungsfenster (Stunden)", + "playbackMode": "Wiedergabemodus", + "paceMode": "Tempo", + "fixedInterval": "Fixes Intervall (Sekunden)", + "layoutMode": "Layout", + "backgroundMode": "Hintergrund", + "effectPreset": "Effekt-Preset", + "effectIntensity": "Effektintensität" + }, + "hints": { + "moderation": "Vertrauenswürdige Quellen sind Admin-Uploads und Fotobooths.", + "retention": "Steuert, wie lange freigegebene Fotos in der Rotation bleiben.", + "playback": "Mischt neue Uploads mit Highlights, wenn die Queue voll ist.", + "pace": "Auto passt sich neuen Uploads an; Fix hält den Takt konstant.", + "layout": "Split- und Grid-Layouts werden genutzt, wenn genug Fotos vorhanden sind." + }, + "moderation": { + "off": "Keine Moderation", + "manual": "Manuelle Freigabe", + "trustedOnly": "Nur vertrauenswürdige Quellen" + }, + "playback": { + "newest": "Neueste zuerst", + "balanced": "Ausgewogene Mischung", + "curated": "Kuratiert" + }, + "pace": { + "auto": "Auto", + "fixed": "Fix" + }, + "layout": { + "single": "Einzelbild", + "split": "Split", + "gridBurst": "Grid-Burst" + }, + "background": { + "blurLast": "Letztes Foto weichzeichnen", + "gradient": "Sanfter Verlauf", + "solid": "Feste Farbe", + "brand": "Brand-Farben" + }, + "effects": { + "filmCut": { + "title": "Film-Schnitt", + "description": "Saubere Blenden mit cineastischem Gefühl." + }, + "shutterFlash": { + "title": "Kamera-Flash", + "description": "Knackiger Flash mit Slide-Übergängen." + }, + "polaroidToss": { + "title": "Polaroid-Wurf", + "description": "Kartenlook mit leichtem Dreh-Settle." + }, + "parallaxGlide": { + "title": "Parallax-Gleiten", + "description": "Sanfter Zoom mit weichem Tiefen-Backdrop." + }, + "light": { + "title": "Light-Effekte", + "description": "Minimalistische Übergänge für schwächere Geräte." + } + } + }, "mobileProfile": { "title": "Profil", "settings": "Einstellungen", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 4347abe..9058831 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -306,6 +306,7 @@ "qr": "QR code layouts", "images": "Image management", "liveShow": "Live Show queue", + "liveShowSettings": "Live Show settings", "guests": "Guest management", "guestMessages": "Guest messages", "branding": "Branding & theme", @@ -2183,6 +2184,84 @@ "notEligible": "Not eligible", "actionFailed": "Live Show update failed." }, + "liveShowSettings": { + "title": "Live Show settings", + "subtitle": "Tune the playback, pacing, and effects shown on the screen.", + "loadFailed": "Live Show settings could not be loaded.", + "save": "Save settings", + "saveSuccess": "Live Show settings updated.", + "saveFailed": "Live Show settings could not be saved.", + "sections": { + "moderation": "Moderation", + "playback": "Playback", + "effects": "Effects & layout" + }, + "fields": { + "moderationMode": "Moderation mode", + "retention": "Retention window (hours)", + "playbackMode": "Playback mode", + "paceMode": "Pace", + "fixedInterval": "Fixed interval (seconds)", + "layoutMode": "Layout", + "backgroundMode": "Background", + "effectPreset": "Effect preset", + "effectIntensity": "Effect intensity" + }, + "hints": { + "moderation": "Trusted sources include admin uploads and photobooths.", + "retention": "Controls how long approved photos remain in rotation.", + "playback": "Balance newer uploads with highlights when the queue is busy.", + "pace": "Auto adapts to new uploads; fixed keeps a steady rhythm.", + "layout": "Split and grid layouts are used when enough photos are available." + }, + "moderation": { + "off": "No moderation", + "manual": "Manual approval", + "trustedOnly": "Trusted sources only" + }, + "playback": { + "newest": "Newest first", + "balanced": "Balanced mix", + "curated": "Curated" + }, + "pace": { + "auto": "Auto", + "fixed": "Fixed" + }, + "layout": { + "single": "Single", + "split": "Split", + "gridBurst": "Grid burst" + }, + "background": { + "blurLast": "Blur last photo", + "gradient": "Soft gradient", + "solid": "Solid color", + "brand": "Brand colors" + }, + "effects": { + "filmCut": { + "title": "Film cut", + "description": "Clean fades with a subtle cinematic feel." + }, + "shutterFlash": { + "title": "Shutter flash", + "description": "Snappy flash and slide transitions." + }, + "polaroidToss": { + "title": "Polaroid toss", + "description": "Card-like frames with a gentle rotation settle." + }, + "parallaxGlide": { + "title": "Parallax glide", + "description": "Slow zoom with a soft depth backdrop." + }, + "light": { + "title": "Light effects", + "description": "Minimal transitions for lower-powered devices." + } + } + }, "mobileProfile": { "title": "Profile", "settings": "Settings", diff --git a/resources/js/admin/mobile/EventDetailPage.tsx b/resources/js/admin/mobile/EventDetailPage.tsx index b5a9c78..0aa0dc4 100644 --- a/resources/js/admin/mobile/EventDetailPage.tsx +++ b/resources/js/admin/mobile/EventDetailPage.tsx @@ -8,7 +8,7 @@ import { Pressable } from '@tamagui/react-native-web-lite'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, PillBadge, KpiTile, ActionTile } from './components/Primitives'; import { TenantEvent, EventStats, EventToolkit, getEvent, getEventStats, getEventToolkit, getEvents } from '../api'; -import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_LIVE_SHOW_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants'; +import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_LIVE_SHOW_PATH, ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; import { MobileSheet } from './components/Sheet'; @@ -258,12 +258,20 @@ export default function MobileEventDetailPage() { disabled={!slug} delayMs={ADMIN_MOTION.tileStaggerMs * 3} /> + slug && navigate(ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH(slug))} + disabled={!slug} + delayMs={ADMIN_MOTION.tileStaggerMs * 4} + /> navigate(adminPath(`/mobile/events/${slug ?? ''}/members`))} - delayMs={ADMIN_MOTION.tileStaggerMs * 4} + delayMs={ADMIN_MOTION.tileStaggerMs * 5} /> slug && navigate(ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH(slug))} disabled={!slug} - delayMs={ADMIN_MOTION.tileStaggerMs * 5} + delayMs={ADMIN_MOTION.tileStaggerMs * 6} /> navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`)) : undefined } disabled={!brandingAllowed} - delayMs={ADMIN_MOTION.tileStaggerMs * 6} + delayMs={ADMIN_MOTION.tileStaggerMs * 7} /> navigate(adminPath(`/mobile/events/${slug ?? ''}/photobooth`))} - delayMs={ADMIN_MOTION.tileStaggerMs * 7} + delayMs={ADMIN_MOTION.tileStaggerMs * 8} /> {isPastEvent(event?.event_date) ? ( navigate(adminPath(`/mobile/events/${slug ?? ''}/recap`))} - delayMs={ADMIN_MOTION.tileStaggerMs * 8} + delayMs={ADMIN_MOTION.tileStaggerMs * 9} /> ) : null} diff --git a/resources/js/admin/mobile/EventLiveShowSettingsPage.tsx b/resources/js/admin/mobile/EventLiveShowSettingsPage.tsx new file mode 100644 index 0000000..11a97fe --- /dev/null +++ b/resources/js/admin/mobile/EventLiveShowSettingsPage.tsx @@ -0,0 +1,519 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { RefreshCcw, Settings } from 'lucide-react'; +import { YStack, XStack } from '@tamagui/stacks'; +import { SizableText as Text } from '@tamagui/text'; +import toast from 'react-hot-toast'; +import { HeaderActionButton, MobileShell } from './components/MobileShell'; +import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; +import { MobileField, MobileInput, MobileSelect } from './components/FormControls'; +import { getEvent, updateEvent, LiveShowSettings, TenantEvent } from '../api'; +import { isAuthError } from '../auth/tokens'; +import { getApiErrorMessage } from '../lib/apiError'; +import { formatEventDate, resolveEventDisplayName } from '../lib/events'; +import { adminPath } from '../constants'; +import { useBackNavigation } from './hooks/useBackNavigation'; +import { useAdminTheme } from './theme'; + +type LiveShowFormState = { + moderation_mode: NonNullable; + retention_window_hours: number; + playback_mode: NonNullable; + pace_mode: NonNullable; + fixed_interval_seconds: number; + layout_mode: NonNullable; + effect_preset: NonNullable; + effect_intensity: number; + background_mode: NonNullable; +}; + +const DEFAULT_LIVE_SHOW_SETTINGS: LiveShowFormState = { + moderation_mode: 'manual', + retention_window_hours: 12, + playback_mode: 'newest_first', + pace_mode: 'auto', + fixed_interval_seconds: 8, + layout_mode: 'single', + effect_preset: 'film_cut', + effect_intensity: 70, + background_mode: 'blur_last', +}; + +const MODERATION_VALUES: LiveShowFormState['moderation_mode'][] = ['off', 'manual', 'trusted_only']; +const PLAYBACK_VALUES: LiveShowFormState['playback_mode'][] = ['newest_first', 'balanced', 'curated']; +const PACE_VALUES: LiveShowFormState['pace_mode'][] = ['auto', 'fixed']; +const LAYOUT_VALUES: LiveShowFormState['layout_mode'][] = ['single', 'split', 'grid_burst']; +const BACKGROUND_VALUES: LiveShowFormState['background_mode'][] = ['blur_last', 'gradient', 'solid', 'brand']; +const EFFECT_VALUES: LiveShowFormState['effect_preset'][] = [ + 'film_cut', + 'shutter_flash', + 'polaroid_toss', + 'parallax_glide', + 'light_effects', +]; + +const MODERATION_OPTIONS: Array<{ value: LiveShowFormState['moderation_mode']; labelKey: string; fallback: string }> = [ + { value: 'off', labelKey: 'liveShowSettings.moderation.off', fallback: 'No moderation' }, + { value: 'manual', labelKey: 'liveShowSettings.moderation.manual', fallback: 'Manual approval' }, + { value: 'trusted_only', labelKey: 'liveShowSettings.moderation.trustedOnly', fallback: 'Trusted sources only' }, +]; + +const PLAYBACK_OPTIONS: Array<{ value: LiveShowFormState['playback_mode']; labelKey: string; fallback: string }> = [ + { value: 'newest_first', labelKey: 'liveShowSettings.playback.newest', fallback: 'Newest first' }, + { value: 'balanced', labelKey: 'liveShowSettings.playback.balanced', fallback: 'Balanced mix' }, + { value: 'curated', labelKey: 'liveShowSettings.playback.curated', fallback: 'Curated' }, +]; + +const PACE_OPTIONS: Array<{ value: LiveShowFormState['pace_mode']; labelKey: string; fallback: string }> = [ + { value: 'auto', labelKey: 'liveShowSettings.pace.auto', fallback: 'Auto' }, + { value: 'fixed', labelKey: 'liveShowSettings.pace.fixed', fallback: 'Fixed' }, +]; + +const LAYOUT_OPTIONS: Array<{ value: LiveShowFormState['layout_mode']; labelKey: string; fallback: string }> = [ + { value: 'single', labelKey: 'liveShowSettings.layout.single', fallback: 'Single' }, + { value: 'split', labelKey: 'liveShowSettings.layout.split', fallback: 'Split' }, + { value: 'grid_burst', labelKey: 'liveShowSettings.layout.gridBurst', fallback: 'Grid burst' }, +]; + +const BACKGROUND_OPTIONS: Array<{ value: LiveShowFormState['background_mode']; labelKey: string; fallback: string }> = [ + { value: 'blur_last', labelKey: 'liveShowSettings.background.blurLast', fallback: 'Blur last photo' }, + { value: 'gradient', labelKey: 'liveShowSettings.background.gradient', fallback: 'Soft gradient' }, + { value: 'solid', labelKey: 'liveShowSettings.background.solid', fallback: 'Solid color' }, + { value: 'brand', labelKey: 'liveShowSettings.background.brand', fallback: 'Brand colors' }, +]; + +const EFFECT_PRESETS: Array<{ + value: LiveShowFormState['effect_preset']; + labelKey: string; + descriptionKey: string; + fallbackLabel: string; + fallbackDescription: string; +}> = [ + { + value: 'film_cut', + labelKey: 'liveShowSettings.effects.filmCut.title', + descriptionKey: 'liveShowSettings.effects.filmCut.description', + fallbackLabel: 'Film cut', + fallbackDescription: 'Clean fades with a subtle cinematic feel.', + }, + { + value: 'shutter_flash', + labelKey: 'liveShowSettings.effects.shutterFlash.title', + descriptionKey: 'liveShowSettings.effects.shutterFlash.description', + fallbackLabel: 'Shutter flash', + fallbackDescription: 'Snappy flash and slide transitions.', + }, + { + value: 'polaroid_toss', + labelKey: 'liveShowSettings.effects.polaroidToss.title', + descriptionKey: 'liveShowSettings.effects.polaroidToss.description', + fallbackLabel: 'Polaroid toss', + fallbackDescription: 'Card-like frames with a gentle rotation settle.', + }, + { + value: 'parallax_glide', + labelKey: 'liveShowSettings.effects.parallaxGlide.title', + descriptionKey: 'liveShowSettings.effects.parallaxGlide.description', + fallbackLabel: 'Parallax glide', + fallbackDescription: 'Slow zoom with a soft depth backdrop.', + }, + { + value: 'light_effects', + labelKey: 'liveShowSettings.effects.light.title', + descriptionKey: 'liveShowSettings.effects.light.description', + fallbackLabel: 'Light effects', + fallbackDescription: 'Minimal transitions for lower-powered devices.', + }, +]; + +export default function MobileEventLiveShowSettingsPage() { + const { slug: slugParam } = useParams<{ slug?: string }>(); + const slug = slugParam ?? null; + const { t, i18n } = useTranslation('management'); + const { text, muted, danger, border, surface } = useAdminTheme(); + const [event, setEvent] = React.useState(null); + const [form, setForm] = React.useState(DEFAULT_LIVE_SHOW_SETTINGS); + const [loading, setLoading] = React.useState(true); + const [saving, setSaving] = React.useState(false); + const [error, setError] = React.useState(null); + const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); + + const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; + + const load = React.useCallback(async () => { + if (!slug) return; + setLoading(true); + setError(null); + try { + const data = await getEvent(slug); + setEvent(data); + setForm(extractSettings(data)); + } catch (err) { + if (!isAuthError(err)) { + setError(getApiErrorMessage(err, t('liveShowSettings.loadFailed', 'Live Show settings could not be loaded.'))); + } + } finally { + setLoading(false); + } + }, [slug, t]); + + React.useEffect(() => { + void load(); + }, [load]); + + async function handleSave() { + if (!event?.slug || saving) return; + const eventTypeId = event.event_type_id ?? event.event_type?.id ?? null; + if (!eventTypeId) { + const msg = t('events.errors.missingType', 'Event type fehlt. Speichere das Event erneut im Admin.'); + setError(msg); + toast.error(msg); + return; + } + + setSaving(true); + setError(null); + try { + const payload = { + name: typeof event.name === 'string' ? event.name : resolveName(event.name), + slug: event.slug, + event_type_id: eventTypeId, + event_date: event.event_date ?? undefined, + status: event.status ?? 'draft', + is_active: event.is_active ?? undefined, + }; + + const updated = await updateEvent(event.slug, { + ...payload, + settings: { + ...(event.settings ?? {}), + live_show: { + moderation_mode: form.moderation_mode, + retention_window_hours: Math.round(form.retention_window_hours), + playback_mode: form.playback_mode, + pace_mode: form.pace_mode, + fixed_interval_seconds: Math.round(form.fixed_interval_seconds), + layout_mode: form.layout_mode, + effect_preset: form.effect_preset, + effect_intensity: Math.round(form.effect_intensity), + background_mode: form.background_mode, + }, + }, + }); + + setEvent(updated); + setForm(extractSettings(updated)); + toast.success(t('liveShowSettings.saveSuccess', 'Live Show settings updated.')); + } catch (err) { + if (!isAuthError(err)) { + const message = getApiErrorMessage(err, t('liveShowSettings.saveFailed', 'Live Show settings could not be saved.')); + setError(message); + toast.error(message); + } + } finally { + setSaving(false); + } + } + + const presetMeta = EFFECT_PRESETS.find((preset) => preset.value === form.effect_preset) ?? EFFECT_PRESETS[0]; + + return ( + load()} ariaLabel={t('common.refresh', 'Refresh')}> + + + } + > + {error ? ( + + + {error} + + + ) : null} + + {loading ? ( + + {Array.from({ length: 3 }).map((_, idx) => ( + + ))} + + ) : ( + + + + + + {t('liveShowSettings.title', 'Live Show settings')} + + + + {t('liveShowSettings.subtitle', 'Tune the playback, pacing, and effects shown on the screen.')} + + + + + + {t('liveShowSettings.sections.moderation', 'Moderation')} + + + setForm((prev) => ({ ...prev, moderation_mode: event.target.value as LiveShowFormState['moderation_mode'] }))} + > + {MODERATION_OPTIONS.map((option) => ( + + ))} + + + + + setForm((prev) => ({ + ...prev, + retention_window_hours: clampNumber(event.target.value, prev.retention_window_hours, 1, 72), + }))} + /> + + + + + + {t('liveShowSettings.sections.playback', 'Playback')} + + + setForm((prev) => ({ ...prev, playback_mode: event.target.value as LiveShowFormState['playback_mode'] }))} + > + {PLAYBACK_OPTIONS.map((option) => ( + + ))} + + + + + setForm((prev) => ({ ...prev, pace_mode: event.target.value as LiveShowFormState['pace_mode'] }))} + > + {PACE_OPTIONS.map((option) => ( + + ))} + + + + {form.pace_mode === 'fixed' ? ( + + setForm((prev) => ({ + ...prev, + fixed_interval_seconds: clampNumber(event.target.value, prev.fixed_interval_seconds, 3, 20), + }))} + /> + + ) : null} + + + + + {t('liveShowSettings.sections.effects', 'Effects & layout')} + + + setForm((prev) => ({ ...prev, layout_mode: event.target.value as LiveShowFormState['layout_mode'] }))} + > + {LAYOUT_OPTIONS.map((option) => ( + + ))} + + + + + setForm((prev) => ({ ...prev, background_mode: event.target.value as LiveShowFormState['background_mode'] }))} + > + {BACKGROUND_OPTIONS.map((option) => ( + + ))} + + + + + setForm((prev) => ({ ...prev, effect_preset: event.target.value as LiveShowFormState['effect_preset'] }))} + > + {EFFECT_PRESETS.map((preset) => ( + + ))} + + + + + + {t(presetMeta.labelKey, presetMeta.fallbackLabel)} + + + {t(presetMeta.descriptionKey, presetMeta.fallbackDescription)} + + + + setForm((prev) => ({ ...prev, effect_intensity: value }))} + suffix="%" + /> + + + handleSave()} + disabled={saving} + /> + + )} + + ); +} + +function extractSettings(event: TenantEvent | null): LiveShowFormState { + const liveShow = (event?.settings?.live_show ?? {}) as LiveShowSettings; + + return { + moderation_mode: resolveOption(liveShow.moderation_mode, MODERATION_VALUES, DEFAULT_LIVE_SHOW_SETTINGS.moderation_mode), + retention_window_hours: resolveNumber(liveShow.retention_window_hours, DEFAULT_LIVE_SHOW_SETTINGS.retention_window_hours, 1, 72), + playback_mode: resolveOption(liveShow.playback_mode, PLAYBACK_VALUES, DEFAULT_LIVE_SHOW_SETTINGS.playback_mode), + pace_mode: resolveOption(liveShow.pace_mode, PACE_VALUES, DEFAULT_LIVE_SHOW_SETTINGS.pace_mode), + fixed_interval_seconds: resolveNumber(liveShow.fixed_interval_seconds, DEFAULT_LIVE_SHOW_SETTINGS.fixed_interval_seconds, 3, 20), + layout_mode: resolveOption(liveShow.layout_mode, LAYOUT_VALUES, DEFAULT_LIVE_SHOW_SETTINGS.layout_mode), + effect_preset: resolveOption(liveShow.effect_preset, EFFECT_VALUES, DEFAULT_LIVE_SHOW_SETTINGS.effect_preset), + effect_intensity: resolveNumber(liveShow.effect_intensity, DEFAULT_LIVE_SHOW_SETTINGS.effect_intensity, 0, 100), + background_mode: resolveOption(liveShow.background_mode, BACKGROUND_VALUES, DEFAULT_LIVE_SHOW_SETTINGS.background_mode), + }; +} + +function resolveName(name: TenantEvent['name']): string { + if (typeof name === 'string' && name.trim()) return name; + if (name && typeof name === 'object') { + return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event'; + } + return 'Event'; +} + +function resolveOption(value: unknown, options: T[], fallback: T): T { + if (typeof value !== 'string') return fallback; + return options.includes(value as T) ? (value as T) : fallback; +} + +function resolveNumber(value: unknown, fallback: number, min: number, max: number): number { + if (typeof value !== 'number' || Number.isNaN(value)) return fallback; + return Math.min(max, Math.max(min, value)); +} + +function clampNumber(value: string, fallback: number, min: number, max: number): number { + const parsed = Number(value); + if (Number.isNaN(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); +} + +function EffectSlider({ + label, + value, + min, + max, + step, + onChange, + disabled, + suffix, +}: { + label: string; + value: number; + min: number; + max: number; + step: number; + onChange: (value: number) => void; + disabled?: boolean; + suffix?: string; +}) { + const { text, muted } = useAdminTheme(); + + return ( + + + + {label} + + + {value} + {suffix ? ` ${suffix}` : ''} + + + onChange(Number(event.target.value))} + style={{ width: '100%' }} + /> + + ); +} diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index 28e4293..6e83f7a 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -30,6 +30,7 @@ const MobileQrLayoutCustomizePage = React.lazy(() => import('./mobile/QrLayoutCu const MobileEventGuestNotificationsPage = React.lazy(() => import('./mobile/EventGuestNotificationsPage')); const MobileEventPhotosPage = React.lazy(() => import('./mobile/EventPhotosPage')); const MobileEventLiveShowQueuePage = React.lazy(() => import('./mobile/EventLiveShowQueuePage')); +const MobileEventLiveShowSettingsPage = React.lazy(() => import('./mobile/EventLiveShowSettingsPage')); const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage')); const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage')); const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage')); @@ -196,6 +197,7 @@ export const router = createBrowserRouter([ { path: 'mobile/events/:slug/qr/customize/:tokenId?', element: }, { path: 'mobile/events/:slug/photos/:photoId?', element: }, { path: 'mobile/events/:slug/live-show', element: }, + { path: 'mobile/events/:slug/live-show/settings', element: }, { path: 'mobile/events/:slug/recap', element: }, { path: 'mobile/events/:slug/members', element: }, { path: 'mobile/events/:slug/tasks', element: }, diff --git a/tests/Feature/EventControllerTest.php b/tests/Feature/EventControllerTest.php index 259317b..1bc1261 100644 --- a/tests/Feature/EventControllerTest.php +++ b/tests/Feature/EventControllerTest.php @@ -138,6 +138,50 @@ class EventControllerTest extends TenantTestCase ->assertJsonPath('error.code', 'event_limit_exceeded'); } + public function test_update_event_accepts_live_show_settings(): void + { + $eventType = EventType::factory()->create(); + $event = Event::factory()->for($this->tenant)->create([ + 'event_type_id' => $eventType->id, + 'name' => 'Live Show Event', + 'slug' => 'live-show-settings', + 'date' => now()->addDays(5), + ]); + + $response = $this->authenticatedRequest('PUT', "/api/v1/tenant/events/{$event->slug}", [ + 'name' => 'Live Show Event', + 'event_date' => now()->addDays(5)->toDateString(), + 'event_type_id' => $eventType->id, + 'settings' => [ + 'live_show' => [ + 'moderation_mode' => 'manual', + 'retention_window_hours' => 12, + 'playback_mode' => 'balanced', + 'pace_mode' => 'fixed', + 'fixed_interval_seconds' => 9, + 'layout_mode' => 'single', + 'effect_preset' => 'film_cut', + 'effect_intensity' => 60, + 'background_mode' => 'blur_last', + ], + ], + ]); + + $response->assertOk(); + + $event->refresh(); + $settings = $event->settings; + $this->assertSame('manual', data_get($settings, 'live_show.moderation_mode')); + $this->assertSame(12, data_get($settings, 'live_show.retention_window_hours')); + $this->assertSame('balanced', data_get($settings, 'live_show.playback_mode')); + $this->assertSame('fixed', data_get($settings, 'live_show.pace_mode')); + $this->assertSame(9, data_get($settings, 'live_show.fixed_interval_seconds')); + $this->assertSame('single', data_get($settings, 'live_show.layout_mode')); + $this->assertSame('film_cut', data_get($settings, 'live_show.effect_preset')); + $this->assertSame(60, data_get($settings, 'live_show.effect_intensity')); + $this->assertSame('blur_last', data_get($settings, 'live_show.background_mode')); + } + public function test_upload_exceeds_package_limit_fails(): void { $tenant = $this->tenant;