Files
fotospiel-app/resources/js/admin/mobile/EventLiveShowSettingsPage.tsx
Codex Agent b1f9f7cee0
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Fix TypeScript typecheck errors
2026-01-30 15:56:06 +01:00

730 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 { 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<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={t('liveShowSettings.title', 'Live Show settings')}
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}
<XStack justifyContent="flex-end">
<ContextHelpLink slug="live-show-setup" />
</XStack>
{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: 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<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, primary, border, surface } = 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>
<Slider
width="100%"
min={min}
max={max}
step={step}
value={[value]}
onValueChange={(next: number[]) => onChange(next[0] ?? value)}
disabled={disabled}
>
<Slider.Track height={6} borderRadius={999} backgroundColor={border}>
<Slider.TrackActive backgroundColor={primary} />
</Slider.Track>
<Slider.Thumb index={0} size="$2" backgroundColor={surface} borderColor={primary} borderWidth={2} />
</Slider>
</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 as any, { color }) : children}
</XStack>
</Pressable>
);
}