Add join token TTL policy and Live Show link sharing
This commit is contained in:
@@ -72,6 +72,13 @@ export type LiveShowSettings = {
|
||||
background_mode?: 'blur_last' | 'gradient' | 'solid' | 'brand';
|
||||
};
|
||||
|
||||
export type LiveShowLink = {
|
||||
token: string;
|
||||
url: string;
|
||||
qr_code_data_url: string | null;
|
||||
rotated_at: string | null;
|
||||
};
|
||||
|
||||
export type TenantEvent = {
|
||||
id: number;
|
||||
name: string | Record<string, string>;
|
||||
@@ -1521,6 +1528,42 @@ export type GetLiveShowQueueOptions = {
|
||||
liveStatus?: LiveShowQueueStatus;
|
||||
};
|
||||
|
||||
function normalizeLiveShowLink(payload: JsonValue | LiveShowLink | null | undefined): LiveShowLink {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return {
|
||||
token: '',
|
||||
url: '',
|
||||
qr_code_data_url: null,
|
||||
rotated_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
const record = payload as Record<string, JsonValue>;
|
||||
|
||||
return {
|
||||
token: typeof record.token === 'string' ? record.token : '',
|
||||
url: typeof record.url === 'string' ? record.url : '',
|
||||
qr_code_data_url: typeof record.qr_code_data_url === 'string' ? record.qr_code_data_url : null,
|
||||
rotated_at: typeof record.rotated_at === 'string' ? record.rotated_at : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLiveShowLink(slug: string): Promise<LiveShowLink> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/link`);
|
||||
const data = await jsonOrThrow<{ data?: JsonValue }>(response, 'Failed to load live show link');
|
||||
|
||||
return normalizeLiveShowLink(data.data);
|
||||
}
|
||||
|
||||
export async function rotateLiveShowLink(slug: string): Promise<LiveShowLink> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/link/rotate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await jsonOrThrow<{ data?: JsonValue }>(response, 'Failed to rotate live show link');
|
||||
|
||||
return normalizeLiveShowLink(data.data);
|
||||
}
|
||||
|
||||
export async function getEventPhotos(
|
||||
slug: string,
|
||||
options: GetEventPhotosOptions = {}
|
||||
|
||||
@@ -2183,6 +2183,27 @@
|
||||
"liveShowSettings": {
|
||||
"title": "Live-Show Einstellungen",
|
||||
"subtitle": "Tempo, Layout und Effekte für die Leinwand feinjustieren.",
|
||||
"link": {
|
||||
"title": "Live-Show-Link",
|
||||
"subtitle": "Öffne diesen Link auf einem Bildschirm, um die Live-Show zu starten.",
|
||||
"empty": "Kein Live-Show-Link verfügbar.",
|
||||
"copy": "Kopieren",
|
||||
"share": "Teilen",
|
||||
"open": "Öffnen",
|
||||
"rotate": "Neu generieren",
|
||||
"rotateConfirm": "Live-Show-Link neu generieren? Der aktuelle Link funktioniert dann nicht mehr.",
|
||||
"rotateSuccess": "Live-Show-Link neu generiert.",
|
||||
"rotateFailed": "Live-Show-Link konnte nicht neu generiert werden.",
|
||||
"rotatedAt": "Zuletzt erneuert {{time}}",
|
||||
"noExpiry": "Dauerhaft gültig, bis von Dir erneuert.",
|
||||
"loadFailed": "Live-Show-Link konnte nicht geladen werden.",
|
||||
"copySuccess": "Link kopiert",
|
||||
"copyFailed": "Link konnte nicht kopiert werden",
|
||||
"shareTitle": "Live-Show",
|
||||
"shareText": "Live-Show-Link",
|
||||
"qrAlt": "Live-Show-QR-Code",
|
||||
"downloadQr": "QR herunterladen"
|
||||
},
|
||||
"loadFailed": "Live-Show-Einstellungen konnten nicht geladen werden.",
|
||||
"save": "Einstellungen speichern",
|
||||
"saveSuccess": "Live-Show-Einstellungen gespeichert.",
|
||||
|
||||
@@ -2187,6 +2187,27 @@
|
||||
"liveShowSettings": {
|
||||
"title": "Live Show settings",
|
||||
"subtitle": "Tune the playback, pacing, and effects shown on the screen.",
|
||||
"link": {
|
||||
"title": "Live Show link",
|
||||
"subtitle": "Open this link on a screen to run the Live Show.",
|
||||
"empty": "No Live Show link available.",
|
||||
"copy": "Copy",
|
||||
"share": "Share",
|
||||
"open": "Open",
|
||||
"rotate": "Rotate",
|
||||
"rotateConfirm": "Rotate the Live Show link? The current link will stop working.",
|
||||
"rotateSuccess": "Live Show link rotated.",
|
||||
"rotateFailed": "Live Show link could not be rotated.",
|
||||
"rotatedAt": "Last rotated {{time}}",
|
||||
"noExpiry": "No expiry date (valid until you rotate it).",
|
||||
"loadFailed": "Live Show link could not be loaded.",
|
||||
"copySuccess": "Link copied",
|
||||
"copyFailed": "Link could not be copied",
|
||||
"shareTitle": "Live Show",
|
||||
"shareText": "Live Show link",
|
||||
"qrAlt": "Live Show QR code",
|
||||
"downloadQr": "Download QR"
|
||||
},
|
||||
"loadFailed": "Live Show settings could not be loaded.",
|
||||
"save": "Save settings",
|
||||
"saveSuccess": "Live Show settings updated.",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getApiValidationMessage, isApiError } from '../lib/apiError';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { withAlpha } from './components/colors';
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
@@ -35,7 +36,7 @@ export default function MobileEventFormPage() {
|
||||
const isEdit = Boolean(slug);
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation(['management', 'common']);
|
||||
const { text, muted, subtle, danger } = useAdminTheme();
|
||||
const { text, muted, subtle, danger, border, surface, primary } = useAdminTheme();
|
||||
|
||||
const [form, setForm] = React.useState<FormState>({
|
||||
name: '',
|
||||
@@ -224,10 +225,14 @@ export default function MobileEventFormPage() {
|
||||
|
||||
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<MobileInput
|
||||
type="datetime-local"
|
||||
<NativeDateTimeInput
|
||||
value={form.date}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, date: value }))}
|
||||
border={border}
|
||||
surface={surface}
|
||||
text={text}
|
||||
primary={primary}
|
||||
danger={danger}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<CalendarDays size={16} color={subtle} />
|
||||
@@ -443,6 +448,57 @@ function toDateTimeLocal(value?: string | null): string {
|
||||
return fallback.length >= 16 ? fallback.slice(0, 16) : '';
|
||||
}
|
||||
|
||||
function NativeDateTimeInput({
|
||||
value,
|
||||
onChange,
|
||||
border,
|
||||
surface,
|
||||
text,
|
||||
primary,
|
||||
danger,
|
||||
hasError,
|
||||
style,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
border: string;
|
||||
surface: string;
|
||||
text: string;
|
||||
primary: string;
|
||||
danger: string;
|
||||
hasError?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
|
||||
const borderColor = hasError ? danger : focused ? primary : border;
|
||||
|
||||
return (
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 44,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
borderColor,
|
||||
backgroundColor: surface,
|
||||
color: text,
|
||||
fontSize: 14,
|
||||
boxShadow: focused ? `0 0 0 3px ${ringColor}` : undefined,
|
||||
outline: 'none',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLocation(event: TenantEvent): string {
|
||||
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
||||
const candidate =
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,9 @@ vi.mock('../theme', () => ({
|
||||
muted: '#6b7280',
|
||||
subtle: '#94a3b8',
|
||||
danger: '#b91c1c',
|
||||
border: '#e5e7eb',
|
||||
surface: '#ffffff',
|
||||
primary: '#ff5a5f',
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user