Add Live Show settings in admin app

This commit is contained in:
Codex Agent
2026-01-05 15:02:21 +01:00
parent 99186e8e2f
commit 7bbce79394
11 changed files with 771 additions and 7 deletions

View File

@@ -1 +1 @@
fotospiel-app-xg5
fotospiel-app-1we

View File

@@ -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,

View File

@@ -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'],

View File

@@ -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<string, string>;
@@ -80,6 +92,7 @@ export type TenantEvent = {
settings?: Record<string, unknown> & {
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<string, unknown> & {
live_show?: LiveShowSettings;
watermark?: WatermarkSettings;
watermark_serve_originals?: boolean | null;
};

View File

@@ -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`);

View File

@@ -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",

View File

@@ -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",

View File

@@ -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}
/>
<ActionTile
icon={Settings}
label={t('events.quick.liveShowSettings', 'Live Show settings')}
color={ADMIN_ACTION_COLORS.images}
onPress={() => slug && navigate(ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH(slug))}
disabled={!slug}
delayMs={ADMIN_MOTION.tileStaggerMs * 4}
/>
<ActionTile
icon={Users}
label={t('events.quick.guests', 'Guest Management')}
color={ADMIN_ACTION_COLORS.guests}
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/members`))}
delayMs={ADMIN_MOTION.tileStaggerMs * 4}
delayMs={ADMIN_MOTION.tileStaggerMs * 5}
/>
<ActionTile
icon={Megaphone}
@@ -271,7 +279,7 @@ export default function MobileEventDetailPage() {
color={ADMIN_ACTION_COLORS.guestMessages}
onPress={() => slug && navigate(ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH(slug))}
disabled={!slug}
delayMs={ADMIN_MOTION.tileStaggerMs * 5}
delayMs={ADMIN_MOTION.tileStaggerMs * 6}
/>
<ActionTile
icon={Layout}
@@ -281,14 +289,14 @@ export default function MobileEventDetailPage() {
brandingAllowed ? () => navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`)) : undefined
}
disabled={!brandingAllowed}
delayMs={ADMIN_MOTION.tileStaggerMs * 6}
delayMs={ADMIN_MOTION.tileStaggerMs * 7}
/>
<ActionTile
icon={Camera}
label={t('events.quick.photobooth', 'Photobooth')}
color={ADMIN_ACTION_COLORS.photobooth}
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photobooth`))}
delayMs={ADMIN_MOTION.tileStaggerMs * 7}
delayMs={ADMIN_MOTION.tileStaggerMs * 8}
/>
{isPastEvent(event?.event_date) ? (
<ActionTile
@@ -296,7 +304,7 @@ export default function MobileEventDetailPage() {
label={t('events.quick.recap', 'Recap & Archive')}
color={ADMIN_ACTION_COLORS.recap}
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/recap`))}
delayMs={ADMIN_MOTION.tileStaggerMs * 8}
delayMs={ADMIN_MOTION.tileStaggerMs * 9}
/>
) : null}
</XStack>

View File

@@ -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<LiveShowSettings['moderation_mode']>;
retention_window_hours: number;
playback_mode: NonNullable<LiveShowSettings['playback_mode']>;
pace_mode: NonNullable<LiveShowSettings['pace_mode']>;
fixed_interval_seconds: number;
layout_mode: NonNullable<LiveShowSettings['layout_mode']>;
effect_preset: NonNullable<LiveShowSettings['effect_preset']>;
effect_intensity: number;
background_mode: NonNullable<LiveShowSettings['background_mode']>;
};
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<TenantEvent | null>(null);
const [form, setForm] = React.useState<LiveShowFormState>(DEFAULT_LIVE_SHOW_SETTINGS);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(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 (
<MobileShell
activeTab="home"
title={event ? resolveEventDisplayName(event) : t('liveShowSettings.title', 'Live Show settings')}
subtitle={event?.event_date ? formatEventDate(event.event_date, locale) ?? undefined : undefined}
onBack={back}
headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={text} />
</HeaderActionButton>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
) : null}
{loading ? (
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<SkeletonCard key={`ls-skel-${idx}`} height={110} />
))}
</YStack>
) : (
<YStack space="$2">
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<Settings size={18} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('liveShowSettings.title', 'Live Show settings')}
</Text>
</XStack>
<Text fontSize="$xs" color={muted}>
{t('liveShowSettings.subtitle', 'Tune the playback, pacing, and effects shown on the screen.')}
</Text>
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('liveShowSettings.sections.moderation', 'Moderation')}
</Text>
<MobileField
label={t('liveShowSettings.fields.moderationMode', 'Moderation mode')}
hint={t('liveShowSettings.hints.moderation', 'Trusted sources include admin uploads and photobooths.')}
>
<MobileSelect
value={form.moderation_mode}
onChange={(event) => setForm((prev) => ({ ...prev, moderation_mode: event.target.value as LiveShowFormState['moderation_mode'] }))}
>
{MODERATION_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{t(option.labelKey, option.fallback)}
</option>
))}
</MobileSelect>
</MobileField>
<MobileField
label={t('liveShowSettings.fields.retention', 'Retention window (hours)')}
hint={t('liveShowSettings.hints.retention', 'Controls how long approved photos remain in rotation.')}
>
<MobileInput
type="number"
inputMode="numeric"
min={1}
max={72}
value={String(form.retention_window_hours)}
onChange={(event) => setForm((prev) => ({
...prev,
retention_window_hours: clampNumber(event.target.value, prev.retention_window_hours, 1, 72),
}))}
/>
</MobileField>
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('liveShowSettings.sections.playback', 'Playback')}
</Text>
<MobileField
label={t('liveShowSettings.fields.playbackMode', 'Playback mode')}
hint={t('liveShowSettings.hints.playback', 'Balance newer uploads with highlights when the queue is busy.')}
>
<MobileSelect
value={form.playback_mode}
onChange={(event) => setForm((prev) => ({ ...prev, playback_mode: event.target.value as LiveShowFormState['playback_mode'] }))}
>
{PLAYBACK_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{t(option.labelKey, option.fallback)}
</option>
))}
</MobileSelect>
</MobileField>
<MobileField
label={t('liveShowSettings.fields.paceMode', 'Pace')}
hint={t('liveShowSettings.hints.pace', 'Auto adapts to new uploads; fixed keeps a steady rhythm.')}
>
<MobileSelect
value={form.pace_mode}
onChange={(event) => setForm((prev) => ({ ...prev, pace_mode: event.target.value as LiveShowFormState['pace_mode'] }))}
>
{PACE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{t(option.labelKey, option.fallback)}
</option>
))}
</MobileSelect>
</MobileField>
{form.pace_mode === 'fixed' ? (
<MobileField
label={t('liveShowSettings.fields.fixedInterval', 'Fixed interval (seconds)')}
>
<MobileInput
type="number"
inputMode="numeric"
min={3}
max={20}
value={String(form.fixed_interval_seconds)}
onChange={(event) => setForm((prev) => ({
...prev,
fixed_interval_seconds: clampNumber(event.target.value, prev.fixed_interval_seconds, 3, 20),
}))}
/>
</MobileField>
) : null}
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('liveShowSettings.sections.effects', 'Effects & layout')}
</Text>
<MobileField
label={t('liveShowSettings.fields.layoutMode', 'Layout')}
hint={t('liveShowSettings.hints.layout', 'Split and grid layouts are used when enough photos are available.')}
>
<MobileSelect
value={form.layout_mode}
onChange={(event) => setForm((prev) => ({ ...prev, layout_mode: event.target.value as LiveShowFormState['layout_mode'] }))}
>
{LAYOUT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{t(option.labelKey, option.fallback)}
</option>
))}
</MobileSelect>
</MobileField>
<MobileField
label={t('liveShowSettings.fields.backgroundMode', 'Background')}
>
<MobileSelect
value={form.background_mode}
onChange={(event) => setForm((prev) => ({ ...prev, background_mode: event.target.value as LiveShowFormState['background_mode'] }))}
>
{BACKGROUND_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{t(option.labelKey, option.fallback)}
</option>
))}
</MobileSelect>
</MobileField>
<MobileField
label={t('liveShowSettings.fields.effectPreset', 'Effect preset')}
>
<MobileSelect
value={form.effect_preset}
onChange={(event) => setForm((prev) => ({ ...prev, effect_preset: event.target.value as LiveShowFormState['effect_preset'] }))}
>
{EFFECT_PRESETS.map((preset) => (
<option key={preset.value} value={preset.value}>
{t(preset.labelKey, preset.fallbackLabel)}
</option>
))}
</MobileSelect>
</MobileField>
<MobileCard borderColor={border} backgroundColor={surface}>
<Text fontSize="$sm" fontWeight="700" color={text}>
{t(presetMeta.labelKey, presetMeta.fallbackLabel)}
</Text>
<Text fontSize="$xs" color={muted}>
{t(presetMeta.descriptionKey, presetMeta.fallbackDescription)}
</Text>
</MobileCard>
<EffectSlider
label={t('liveShowSettings.fields.effectIntensity', 'Effect intensity')}
value={form.effect_intensity}
min={0}
max={100}
step={5}
onChange={(value) => setForm((prev) => ({ ...prev, effect_intensity: value }))}
suffix="%"
/>
</MobileCard>
<CTAButton
label={saving ? t('common.processing', '...') : t('liveShowSettings.save', 'Save settings')}
onPress={() => handleSave()}
disabled={saving}
/>
</YStack>
)}
</MobileShell>
);
}
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<T extends string>(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 (
<YStack space="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color={text}>
{label}
</Text>
<Text fontSize="$xs" color={muted}>
{value}
{suffix ? ` ${suffix}` : ''}
</Text>
</XStack>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
style={{ width: '100%' }}
/>
</YStack>
);
}

View File

@@ -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: <RequireAdminAccess><MobileQrLayoutCustomizePage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/photos/:photoId?', element: <MobileEventPhotosPage /> },
{ path: 'mobile/events/:slug/live-show', element: <RequireAdminAccess><MobileEventLiveShowQueuePage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/live-show/settings', element: <RequireAdminAccess><MobileEventLiveShowSettingsPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/recap', element: <RequireAdminAccess><MobileEventRecapPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },

View File

@@ -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;