Add Live Show settings in admin app
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
519
resources/js/admin/mobile/EventLiveShowSettingsPage.tsx
Normal file
519
resources/js/admin/mobile/EventLiveShowSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 /> },
|
||||
|
||||
Reference in New Issue
Block a user