Add join token TTL policy and Live Show link sharing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-05 21:11:36 +01:00
parent 3f3061a899
commit 88012c35bd
18 changed files with 636 additions and 9 deletions

View File

@@ -1,14 +1,15 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { RefreshCcw, Settings } from 'lucide-react';
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, updateEvent, LiveShowSettings, TenantEvent } from '../api';
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';
@@ -131,16 +132,34 @@ 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 { 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);
@@ -149,6 +168,7 @@ export default function MobileEventLiveShowSettingsPage() {
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.')));
@@ -156,7 +176,7 @@ export default function MobileEventLiveShowSettingsPage() {
} finally {
setLoading(false);
}
}, [slug, t]);
}, [slug, t, loadLink]);
React.useEffect(() => {
void load();
@@ -216,6 +236,27 @@ export default function MobileEventLiveShowSettingsPage() {
}
}
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 (
@@ -246,6 +287,87 @@ export default function MobileEventLiveShowSettingsPage() {
</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} />
@@ -456,6 +578,46 @@ function resolveName(name: TenantEvent['name']): string {
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;
@@ -517,3 +679,42 @@ function EffectSlider({
</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>
);
}