Add live show moderation queue

This commit is contained in:
Codex Agent
2026-01-05 14:04:05 +01:00
parent 7802bed394
commit e3b7271f69
16 changed files with 829 additions and 8 deletions

View File

@@ -132,6 +132,11 @@ export type TenantPhoto = {
url: string | null;
thumbnail_url: string | null;
status: string;
live_status?: string | null;
live_approved_at?: string | null;
live_reviewed_at?: string | null;
live_rejection_reason?: string | null;
live_priority?: number | null;
is_featured: boolean;
likes_count: number;
uploaded_at: string;
@@ -911,6 +916,11 @@ function normalizePhoto(photo: TenantPhoto): TenantPhoto {
url: photo.url,
thumbnail_url: photo.thumbnail_url ?? photo.url,
status: photo.status ?? 'approved',
live_status: photo.live_status ?? null,
live_approved_at: photo.live_approved_at ?? null,
live_reviewed_at: photo.live_reviewed_at ?? null,
live_rejection_reason: photo.live_rejection_reason ?? null,
live_priority: typeof photo.live_priority === 'number' ? photo.live_priority : null,
is_featured: Boolean(photo.is_featured),
likes_count: Number(photo.likes_count ?? 0),
uploaded_at: photo.uploaded_at,
@@ -1489,6 +1499,14 @@ export type GetEventPhotosOptions = {
visibility?: 'visible' | 'hidden' | 'all';
};
export type LiveShowQueueStatus = 'pending' | 'approved' | 'rejected' | 'none' | 'expired' | 'all';
export type GetLiveShowQueueOptions = {
page?: number;
perPage?: number;
liveStatus?: LiveShowQueueStatus;
};
export async function getEventPhotos(
slug: string,
options: GetEventPhotosOptions = {}
@@ -1526,6 +1544,67 @@ export async function getEventPhotos(
};
}
export async function getLiveShowQueue(
slug: string,
options: GetLiveShowQueueOptions = {}
): Promise<{ photos: TenantPhoto[]; meta: PaginationMeta }> {
const params = new URLSearchParams();
if (options.page) params.set('page', String(options.page));
if (options.perPage) params.set('per_page', String(options.perPage));
if (options.liveStatus) params.set('live_status', options.liveStatus);
const response = await authorizedFetch(
`${eventEndpoint(slug)}/live-show/photos${params.toString() ? `?${params.toString()}` : ''}`
);
const data = await jsonOrThrow<{
data?: TenantPhoto[];
meta?: Partial<PaginationMeta>;
current_page?: number;
last_page?: number;
per_page?: number;
total?: number;
}>(response, 'Failed to load live show queue');
const meta = buildPagination(data as unknown as JsonValue, options.perPage ?? 20);
return {
photos: (data.data ?? []).map(normalizePhoto),
meta,
};
}
export async function approveLiveShowPhoto(
slug: string,
id: number,
payload: { priority?: number } = {}
): Promise<TenantPhoto> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/photos/${id}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to approve live show photo');
return normalizePhoto(data.data);
}
export async function rejectLiveShowPhoto(slug: string, id: number, reason?: string): Promise<TenantPhoto> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/photos/${id}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason }),
});
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to reject live show photo');
return normalizePhoto(data.data);
}
export async function clearLiveShowPhoto(slug: string, id: number): Promise<TenantPhoto> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/photos/${id}/clear`, {
method: 'POST',
});
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to clear live show photo');
return normalizePhoto(data.data);
}
export async function getEventPhoto(slug: string, id: number): Promise<TenantPhoto> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}`);
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to load photo');

View File

@@ -33,3 +33,4 @@ export const ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH = (slug: string): string =>
adminPath(`/mobile/events/${encodeURIComponent(slug)}/guest-notifications`);
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`);

View File

@@ -310,6 +310,7 @@
"tasks": "Aufgaben & Checklisten",
"qr": "QR-Code-Layouts",
"images": "Bildverwaltung",
"liveShow": "Live-Show-Warteschlange",
"guests": "Gästeverwaltung",
"guestMessages": "Gästebenachrichtigungen",
"branding": "Branding & Design",
@@ -2142,6 +2143,33 @@
"queueWaiting": "Offline",
"syncFailed": "Synchronisierung fehlgeschlagen. Bitte später erneut versuchen."
},
"liveShowQueue": {
"title": "Live-Show-Warteschlange",
"subtitle": "Fotos für die Live-Slideshow freigeben",
"filterLabel": "Live-Status",
"statusPending": "Ausstehend",
"statusApproved": "Freigegeben",
"statusRejected": "Abgelehnt",
"statusNone": "Nicht vorgemerkt",
"status": {
"pending": "Ausstehend",
"approved": "Freigegeben",
"rejected": "Abgelehnt",
"none": "Nicht vorgemerkt"
},
"galleryApproved": "Galerie freigegeben",
"galleryApprovedOnly": "Hier erscheinen nur bereits freigegebene Galerie-Fotos.",
"offlineNotice": "Du bist offline. Live-Show-Aktionen sind deaktiviert.",
"empty": "Keine Fotos für die Live-Show in der Warteschlange.",
"loadFailed": "Live-Show-Warteschlange konnte nicht geladen werden.",
"approve": "Für Live-Show freigeben",
"reject": "Ablehnen",
"clear": "Aus Live-Show entfernen",
"approveSuccess": "Foto für Live-Show freigegeben",
"rejectSuccess": "Foto aus Live-Show entfernt",
"clearSuccess": "Live-Show-Freigabe entfernt",
"actionFailed": "Live-Show-Aktion fehlgeschlagen."
},
"mobileProfile": {
"title": "Profil",
"settings": "Einstellungen",

View File

@@ -305,6 +305,7 @@
"tasks": "Tasks & checklists",
"qr": "QR code layouts",
"images": "Image management",
"liveShow": "Live Show queue",
"guests": "Guest management",
"guestMessages": "Guest messages",
"branding": "Branding & theme",
@@ -2146,6 +2147,33 @@
"queueWaiting": "Offline",
"syncFailed": "Sync failed. Please try again later."
},
"liveShowQueue": {
"title": "Live Show queue",
"subtitle": "Approve photos for the live slideshow",
"filterLabel": "Live status",
"statusPending": "Pending",
"statusApproved": "Approved",
"statusRejected": "Rejected",
"statusNone": "Not queued",
"status": {
"pending": "Pending",
"approved": "Approved",
"rejected": "Rejected",
"none": "Not queued"
},
"galleryApproved": "Gallery approved",
"galleryApprovedOnly": "Only gallery-approved photos appear here.",
"offlineNotice": "You are offline. Live Show actions are disabled.",
"empty": "No photos waiting for Live Show.",
"loadFailed": "Live Show queue could not be loaded.",
"approve": "Approve for Live Show",
"reject": "Reject",
"clear": "Remove from Live Show",
"approveSuccess": "Photo approved for Live Show",
"rejectSuccess": "Photo removed from Live Show",
"clearSuccess": "Live Show approval removed",
"actionFailed": "Live Show update failed."
},
"mobileProfile": {
"title": "Profile",
"settings": "Settings",

View File

@@ -1,14 +1,14 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, Pencil, Megaphone } from 'lucide-react';
import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, Pencil, Megaphone, Tv } 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 { 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_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_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';
@@ -250,12 +250,20 @@ export default function MobileEventDetailPage() {
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))}
delayMs={ADMIN_MOTION.tileStaggerMs * 2}
/>
<ActionTile
icon={Tv}
label={t('events.quick.liveShow', 'Live Show queue')}
color={ADMIN_ACTION_COLORS.images}
onPress={() => slug && navigate(ADMIN_EVENT_LIVE_SHOW_PATH(slug))}
disabled={!slug}
delayMs={ADMIN_MOTION.tileStaggerMs * 3}
/>
<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 * 3}
delayMs={ADMIN_MOTION.tileStaggerMs * 4}
/>
<ActionTile
icon={Megaphone}
@@ -263,7 +271,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 * 4}
delayMs={ADMIN_MOTION.tileStaggerMs * 5}
/>
<ActionTile
icon={Layout}
@@ -273,14 +281,14 @@ export default function MobileEventDetailPage() {
brandingAllowed ? () => navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`)) : undefined
}
disabled={!brandingAllowed}
delayMs={ADMIN_MOTION.tileStaggerMs * 5}
delayMs={ADMIN_MOTION.tileStaggerMs * 6}
/>
<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 * 6}
delayMs={ADMIN_MOTION.tileStaggerMs * 7}
/>
{isPastEvent(event?.event_date) ? (
<ActionTile
@@ -288,7 +296,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 * 7}
delayMs={ADMIN_MOTION.tileStaggerMs * 8}
/>
) : null}
</XStack>

View File

@@ -0,0 +1,309 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { RefreshCcw } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { MobileSelect } from './components/FormControls';
import { useEventContext } from '../context/EventContext';
import {
approveLiveShowPhoto,
clearLiveShowPhoto,
getEvents,
getLiveShowQueue,
LiveShowQueueStatus,
rejectLiveShowPhoto,
TenantEvent,
TenantPhoto,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { adminPath } from '../constants';
import toast from 'react-hot-toast';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
import { useOnlineStatus } from './hooks/useOnlineStatus';
const STATUS_OPTIONS: Array<{ value: LiveShowQueueStatus; labelKey: string; fallback: string }> = [
{ value: 'pending', labelKey: 'liveShowQueue.statusPending', fallback: 'Pending' },
{ value: 'approved', labelKey: 'liveShowQueue.statusApproved', fallback: 'Approved' },
{ value: 'rejected', labelKey: 'liveShowQueue.statusRejected', fallback: 'Rejected' },
{ value: 'none', labelKey: 'liveShowQueue.statusNone', fallback: 'Not queued' },
];
export default function MobileEventLiveShowQueuePage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t } = useTranslation('management');
const { activeEvent, selectEvent } = useEventContext();
const slug = slugParam ?? activeEvent?.slug ?? null;
const online = useOnlineStatus();
const { textStrong, text, muted, border, danger } = useAdminTheme();
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
const [statusFilter, setStatusFilter] = React.useState<LiveShowQueueStatus>('pending');
const [page, setPage] = React.useState(1);
const [hasMore, setHasMore] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [busyId, setBusyId] = React.useState<number | null>(null);
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam);
}
}, [slugParam, activeEvent?.slug, selectEvent]);
const loadQueue = React.useCallback(async () => {
if (!slug) {
if (!fallbackAttempted) {
setFallbackAttempted(true);
try {
const events = await getEvents({ force: true });
const first = events[0] as TenantEvent | undefined;
if (first?.slug) {
selectEvent(first.slug);
navigate(adminPath(`/mobile/events/${first.slug}/live-show`), { replace: true });
}
} catch {
// ignore
}
}
setLoading(false);
setError(t('events.errors.missingSlug', 'No event selected.'));
return;
}
setLoading(true);
setError(null);
try {
const result = await getLiveShowQueue(slug, {
page,
perPage: 20,
liveStatus: statusFilter,
});
setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos]));
const lastPage = result.meta?.last_page ?? 1;
setHasMore(page < lastPage);
} catch (err) {
if (!isAuthError(err)) {
const message = getApiErrorMessage(err, t('liveShowQueue.loadFailed', 'Live Show queue could not be loaded.'));
setError(message);
toast.error(message);
}
} finally {
setLoading(false);
}
}, [slug, page, statusFilter, fallbackAttempted, navigate, selectEvent, t]);
React.useEffect(() => {
setPage(1);
}, [statusFilter]);
React.useEffect(() => {
void loadQueue();
}, [loadQueue]);
async function handleApprove(photo: TenantPhoto) {
if (!slug || busyId) return;
setBusyId(photo.id);
try {
const updated = await approveLiveShowPhoto(slug, photo.id);
setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
toast.success(t('liveShowQueue.approveSuccess', 'Photo approved for Live Show'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
}
} finally {
setBusyId(null);
}
}
async function handleReject(photo: TenantPhoto) {
if (!slug || busyId) return;
setBusyId(photo.id);
try {
const updated = await rejectLiveShowPhoto(slug, photo.id);
setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
toast.success(t('liveShowQueue.rejectSuccess', 'Photo removed from Live Show'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
}
} finally {
setBusyId(null);
}
}
async function handleClear(photo: TenantPhoto) {
if (!slug || busyId) return;
setBusyId(photo.id);
try {
const updated = await clearLiveShowPhoto(slug, photo.id);
setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
toast.success(t('liveShowQueue.clearSuccess', 'Live Show approval removed'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
}
} finally {
setBusyId(null);
}
}
function resolveStatusTone(status?: string | null): 'success' | 'warning' | 'muted' {
if (status === 'approved') return 'success';
if (status === 'pending') return 'warning';
return 'muted';
}
return (
<MobileShell
activeTab="home"
title={t('liveShowQueue.title', 'Live Show queue')}
subtitle={t('liveShowQueue.subtitle', 'Approve photos for the live slideshow')}
onBack={back}
headerActions={
<HeaderActionButton onPress={() => loadQueue()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
}
>
<MobileCard borderColor={border} backgroundColor="transparent">
<Text fontSize="$sm" color={muted}>
{t('liveShowQueue.galleryApprovedOnly', 'Only gallery-approved photos appear here.')}
</Text>
{!online ? (
<Text fontSize="$sm" color={danger}>
{t('liveShowQueue.offlineNotice', 'You are offline. Live Show actions are disabled.')}
</Text>
) : null}
</MobileCard>
<MobileCard>
<MobileSelect
label={t('liveShowQueue.filterLabel', 'Live status')}
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as LiveShowQueueStatus)}
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{t(option.labelKey, option.fallback)}
</option>
))}
</MobileSelect>
</MobileCard>
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
) : null}
{loading && page === 1 ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<SkeletonCard key={`skeleton-${idx}`} height={120} />
))}
</YStack>
) : photos.length === 0 ? (
<MobileCard>
<Text fontWeight="700" color={text}>
{t('liveShowQueue.empty', 'No photos waiting for Live Show.')}
</Text>
</MobileCard>
) : (
<YStack space="$2">
{photos.map((photo) => {
const isBusy = busyId === photo.id;
const liveStatus = photo.live_status ?? 'pending';
return (
<MobileCard key={photo.id}>
<XStack space="$3" alignItems="center">
{photo.thumbnail_url ? (
<img
src={photo.thumbnail_url}
alt={photo.original_name ?? 'Photo'}
style={{
width: 86,
height: 86,
borderRadius: 14,
objectFit: 'cover',
border: `1px solid ${border}`,
}}
/>
) : null}
<YStack flex={1} space="$2">
<XStack alignItems="center" space="$2">
<PillBadge tone="success">
{t('liveShowQueue.galleryApproved', 'Gallery approved')}
</PillBadge>
<PillBadge tone={resolveStatusTone(liveStatus)}>
{t(`liveShowQueue.status.${liveStatus}`, liveStatus)}
</PillBadge>
</XStack>
<Text fontSize="$sm" color={muted}>
{photo.uploaded_at}
</Text>
</YStack>
</XStack>
<XStack space="$2" marginTop="$2">
{liveStatus !== 'approved' ? (
<CTAButton
label={t('liveShowQueue.approve', 'Approve for Live Show')}
onPress={() => handleApprove(photo)}
disabled={!online}
loading={isBusy}
tone="primary"
/>
) : (
<CTAButton
label={t('liveShowQueue.clear', 'Remove from Live Show')}
onPress={() => handleClear(photo)}
disabled={!online}
loading={isBusy}
tone="ghost"
/>
)}
{liveStatus !== 'rejected' ? (
<CTAButton
label={t('liveShowQueue.reject', 'Reject')}
onPress={() => handleReject(photo)}
disabled={!online}
loading={isBusy}
tone="danger"
/>
) : (
<CTAButton
label={t('liveShowQueue.clear', 'Remove from Live Show')}
onPress={() => handleClear(photo)}
disabled={!online}
loading={isBusy}
tone="ghost"
/>
)}
</XStack>
</MobileCard>
);
})}
</YStack>
)}
{hasMore ? (
<MobileCard>
<CTAButton
label={t('common.loadMore', 'Load more')}
onPress={() => setPage((prev) => prev + 1)}
disabled={loading}
/>
</MobileCard>
) : null}
</MobileShell>
);
}

View File

@@ -29,6 +29,7 @@ const MobileQrPrintPage = React.lazy(() => import('./mobile/QrPrintPage'));
const MobileQrLayoutCustomizePage = React.lazy(() => import('./mobile/QrLayoutCustomizePage'));
const MobileEventGuestNotificationsPage = React.lazy(() => import('./mobile/EventGuestNotificationsPage'));
const MobileEventPhotosPage = React.lazy(() => import('./mobile/EventPhotosPage'));
const MobileEventLiveShowQueuePage = React.lazy(() => import('./mobile/EventLiveShowQueuePage'));
const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage'));
const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage'));
const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage'));
@@ -194,6 +195,7 @@ export const router = createBrowserRouter([
{ path: 'mobile/events/:slug/qr', element: <RequireAdminAccess><MobileQrPrintPage /></RequireAdminAccess> },
{ 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/recap', element: <RequireAdminAccess><MobileEventRecapPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },