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%' }} /> ); }