721 lines
28 KiB
TypeScript
721 lines
28 KiB
TypeScript
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 { 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 { 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 { textStrong, 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 [liveShowLink, setLiveShowLink] = React.useState<LiveShowLink | null>(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<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 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 (
|
|
<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">
|
|
<Link2 size={18} color={text} />
|
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
|
{t('liveShowSettings.link.title', 'Live Show link')}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('liveShowSettings.link.subtitle', 'Open this link on a screen to run the Live Show.')}
|
|
</Text>
|
|
{linkLoading ? (
|
|
<SkeletonCard height={96} />
|
|
) : liveShowLink?.url ? (
|
|
<Text fontSize="$sm" color={textStrong} selectable>
|
|
{liveShowLink.url}
|
|
</Text>
|
|
) : (
|
|
<Text fontSize="$sm" color={muted}>
|
|
{t('liveShowSettings.link.empty', 'No Live Show link available.')}
|
|
</Text>
|
|
)}
|
|
<XStack space="$2" marginTop="$2" alignItems="center" flexWrap="nowrap">
|
|
<IconAction
|
|
label={t('liveShowSettings.link.copy', 'Copy')}
|
|
disabled={!liveShowLink?.url}
|
|
onPress={() => liveShowLink?.url && copyToClipboard(liveShowLink.url, t)}
|
|
>
|
|
<Copy size={18} />
|
|
</IconAction>
|
|
<IconAction
|
|
label={t('liveShowSettings.link.share', 'Share')}
|
|
disabled={!liveShowLink?.url}
|
|
onPress={() => liveShowLink?.url && shareLink(liveShowLink.url, event, t)}
|
|
>
|
|
<Share2 size={18} />
|
|
</IconAction>
|
|
<IconAction
|
|
label={t('liveShowSettings.link.open', 'Open')}
|
|
disabled={!liveShowLink?.url}
|
|
onPress={() => liveShowLink?.url && openLink(liveShowLink.url)}
|
|
>
|
|
<ExternalLink size={18} />
|
|
</IconAction>
|
|
<IconAction
|
|
label={t('liveShowSettings.link.rotate', 'Rotate')}
|
|
disabled={linkBusy}
|
|
onPress={() => handleRotateLink()}
|
|
>
|
|
<RotateCcw size={18} />
|
|
</IconAction>
|
|
</XStack>
|
|
{liveShowLink?.qr_code_data_url ? (
|
|
<XStack space="$2" alignItems="center" marginTop="$2" flexWrap="wrap">
|
|
<Pressable
|
|
onPress={() => 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' }}
|
|
>
|
|
<img
|
|
src={liveShowLink.qr_code_data_url}
|
|
alt={t('liveShowSettings.link.qrAlt', 'Live Show QR code')}
|
|
style={{ width: 140, height: 140, borderRadius: 12, border: `1px solid ${border}` }}
|
|
/>
|
|
</Pressable>
|
|
</XStack>
|
|
) : null}
|
|
{liveShowLink ? (
|
|
<Text fontSize="$xs" color={muted} marginTop="$1.5">
|
|
{t('liveShowSettings.link.noExpiry', 'No expiry date set.')}
|
|
</Text>
|
|
) : null}
|
|
{liveShowLink?.rotated_at ? (
|
|
<Text fontSize="$xs" color={muted} marginTop="$1.5">
|
|
{t('liveShowSettings.link.rotatedAt', 'Last rotated {{time}}', {
|
|
time: formatTimestamp(liveShowLink.rotated_at, locale),
|
|
})}
|
|
</Text>
|
|
) : null}
|
|
</MobileCard>
|
|
|
|
<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 copyToClipboard(value: string, t: (key: string, fallback?: string) => string) {
|
|
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: (key: string, fallback?: string) => string) {
|
|
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<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>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Pressable
|
|
onPress={disabled ? undefined : onPress}
|
|
disabled={disabled}
|
|
title={label}
|
|
aria-label={label}
|
|
style={{
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 14,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: surface,
|
|
borderWidth: 1,
|
|
borderColor: border,
|
|
opacity: disabled ? 0.6 : 1,
|
|
}}
|
|
>
|
|
<XStack alignItems="center" justifyContent="center">
|
|
{React.isValidElement(children) ? React.cloneElement(children, { color }) : children}
|
|
</XStack>
|
|
</Pressable>
|
|
);
|
|
}
|