import React from 'react'; import { useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Copy, ExternalLink, Link2, RefreshCcw, RotateCcw, Settings, Share2 } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { Slider } from 'tamagui'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; 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, getLiveShowLink, rotateLiveShowLink, updateEvent, LiveShowLink, LiveShowSettings, TenantEvent } from '../api'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; import { resolveEventDisplayName } from '../lib/events'; import { adminPath } from '../constants'; import { useBackNavigation } from './hooks/useBackNavigation'; import { useAdminTheme } from './theme'; import { ContextHelpLink } from './components/ContextHelpLink'; 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 { textStrong, text, muted, danger, border, surface } = useAdminTheme(); const [event, setEvent] = React.useState(null); const [form, setForm] = React.useState(DEFAULT_LIVE_SHOW_SETTINGS); const [liveShowLink, setLiveShowLink] = React.useState(null); const [loading, setLoading] = React.useState(true); const [linkLoading, setLinkLoading] = React.useState(false); const [linkBusy, setLinkBusy] = React.useState(false); 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 loadLink = React.useCallback(async () => { if (!slug) return; setLinkLoading(true); try { const link = await getLiveShowLink(slug); setLiveShowLink(link); } catch (err) { if (!isAuthError(err)) { toast.error(getApiErrorMessage(err, t('liveShowSettings.link.loadFailed', 'Live Show link could not be loaded.'))); } } finally { setLinkLoading(false); } }, [slug, t]); const load = React.useCallback(async () => { if (!slug) return; setLoading(true); setError(null); try { const data = await getEvent(slug); setEvent(data); setForm(extractSettings(data)); void loadLink(); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, t('liveShowSettings.loadFailed', 'Live Show settings could not be loaded.'))); } } finally { setLoading(false); } }, [slug, t, loadLink]); 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); } } async function handleRotateLink() { if (!slug || linkBusy) return; if (!window.confirm(t('liveShowSettings.link.rotateConfirm', 'Rotate the Live Show link? The current link will stop working.'))) { return; } setLinkBusy(true); try { const link = await rotateLiveShowLink(slug); setLiveShowLink(link); toast.success(t('liveShowSettings.link.rotateSuccess', 'Live Show link rotated.')); } catch (err) { if (!isAuthError(err)) { toast.error(getApiErrorMessage(err, t('liveShowSettings.link.rotateFailed', 'Live Show link could not be rotated.'))); } } finally { setLinkBusy(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.link.title', 'Live Show link')} {t('liveShowSettings.link.subtitle', 'Open this link on a screen to run the Live Show.')} {linkLoading ? ( ) : liveShowLink?.url ? ( {liveShowLink.url} ) : ( {t('liveShowSettings.link.empty', 'No Live Show link available.')} )} liveShowLink?.url && copyToClipboard(liveShowLink.url, t)} > liveShowLink?.url && shareLink(liveShowLink.url, event, t)} > liveShowLink?.url && openLink(liveShowLink.url)} > handleRotateLink()} > {liveShowLink?.qr_code_data_url ? ( downloadQr(liveShowLink.qr_code_data_url!, 'live-show-qr.png')} title={t('liveShowSettings.link.downloadQr', 'Download QR')} aria-label={t('liveShowSettings.link.downloadQr', 'Download QR')} style={{ borderRadius: 12, cursor: 'pointer' }} > {t('liveShowSettings.link.qrAlt', ) : null} {liveShowLink ? ( {t('liveShowSettings.link.noExpiry', 'No expiry date set.')} ) : null} {liveShowLink?.rotated_at ? ( {t('liveShowSettings.link.rotatedAt', 'Last rotated {{time}}', { time: formatTimestamp(liveShowLink.rotated_at, locale), })} ) : null} {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 copyToClipboard(value: string, t: any) { navigator.clipboard .writeText(value) .then(() => toast.success(t('liveShowSettings.link.copySuccess', 'Link copied'))) .catch(() => toast.error(t('liveShowSettings.link.copyFailed', 'Link could not be copied'))); } async function shareLink(value: string, event: TenantEvent | null, t: any) { if (navigator.share) { try { await navigator.share({ title: event ? resolveEventDisplayName(event) : t('liveShowSettings.link.shareTitle', 'Live Show'), text: t('liveShowSettings.link.shareText', 'Live Show link'), url: value, }); return; } catch { // ignore } } copyToClipboard(value, t); } function openLink(value: string) { window.open(value, '_blank', 'noopener,noreferrer'); } function downloadQr(dataUrl: string, filename: string) { const link = document.createElement('a'); link.href = dataUrl; link.download = filename; link.click(); } function formatTimestamp(value: string, locale: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString(locale, { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); } 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, primary, border, surface } = useAdminTheme(); return ( {label} {value} {suffix ? ` ${suffix}` : ''} onChange(next[0] ?? value)} disabled={disabled} > ); } function IconAction({ label, onPress, disabled, children, }: { label: string; onPress: () => void; disabled?: boolean; children: React.ReactNode; }) { const { surface, border, text, muted } = useAdminTheme(); const color = disabled ? muted : text; return ( {React.isValidElement(children) ? React.cloneElement(children as any, { color }) : children} ); }