Files
fotospiel-app/resources/js/admin/mobile/EventControlRoomPage.tsx
Codex Agent e5e74febbd
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Shrink control room photo actions
2026-01-20 14:02:49 +01:00

1109 lines
39 KiB
TypeScript

import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Check, Eye, EyeOff, Image as ImageIcon, RefreshCcw, Settings, Sparkles } 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, CTAButton, SkeletonCard } from './components/Primitives';
import { MobileField, MobileSelect } from './components/FormControls';
import { useEventContext } from '../context/EventContext';
import {
approveAndLiveShowPhoto,
approveLiveShowPhoto,
clearLiveShowPhoto,
createEventAddonCheckout,
EventAddonCatalogItem,
EventLimitSummary,
getAddonCatalog,
featurePhoto,
getEventPhotos,
getEvents,
getLiveShowQueue,
LiveShowQueueStatus,
rejectLiveShowPhoto,
TenantEvent,
TenantPhoto,
unfeaturePhoto,
updatePhotoStatus,
updatePhotoVisibility,
} 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';
import { useAuth } from '../auth/context';
import { withAlpha } from './components/colors';
import {
enqueuePhotoAction,
loadPhotoQueue,
removePhotoAction,
replacePhotoQueue,
type PhotoModerationAction,
} from './lib/photoModerationQueue';
import { triggerHaptic } from './lib/haptics';
import { normalizeLiveStatus, resolveLiveShowApproveMode } from './lib/controlRoom';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import { selectAddonKeyForScope } from './addons';
import { LimitWarnings } from './components/LimitWarnings';
type ModerationFilter = 'all' | 'featured' | 'hidden' | 'pending';
const MODERATION_FILTERS: Array<{ value: ModerationFilter; labelKey: string; fallback: string }> = [
{ value: 'pending', labelKey: 'photos.filters.pending', fallback: 'Pending' },
{ value: 'all', labelKey: 'photos.filters.all', fallback: 'All' },
{ value: 'featured', labelKey: 'photos.filters.featured', fallback: 'Featured' },
{ value: 'hidden', labelKey: 'photos.filters.hidden', fallback: 'Hidden' },
];
const LIVE_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' },
];
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {
const defaults: Record<string, string> = {
photosBlocked: 'Upload limit reached. Buy more photos to continue.',
photosWarning: '{{remaining}} of {{limit}} photos remaining.',
guestsBlocked: 'Guest limit reached.',
guestsWarning: '{{remaining}} of {{limit}} guests remaining.',
galleryExpired: 'Gallery expired. Extend to keep it online.',
galleryWarningHour: 'Gallery expires in {{hours}} hour.',
galleryWarningHours: 'Gallery expires in {{hours}} hours.',
galleryWarningDay: 'Gallery expires in {{days}} day.',
galleryWarningDays: 'Gallery expires in {{days}} days.',
buyMorePhotos: 'Buy more photos',
extendGallery: 'Extend gallery',
buyMoreGuests: 'Add more guests',
};
return (key, options) => t(`limits.${key}`, defaults[key] ?? key, options);
}
type PhotoGridAction = {
key: string;
label: string;
icon: React.ComponentType<{ size?: number; color?: string }>;
onPress: () => void;
disabled?: boolean;
backgroundColor?: string;
iconColor?: string;
};
function PhotoGrid({
photos,
actionsForPhoto,
badgesForPhoto,
isBusy,
}: {
photos: TenantPhoto[];
actionsForPhoto: (photo: TenantPhoto) => PhotoGridAction[];
badgesForPhoto?: (photo: TenantPhoto) => string[];
isBusy?: (photo: TenantPhoto) => boolean;
}) {
const gridStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
gap: 12,
};
return (
<div style={gridStyle}>
{photos.map((photo) => (
<PhotoGridTile
key={photo.id}
photo={photo}
actions={actionsForPhoto(photo)}
badges={badgesForPhoto ? badgesForPhoto(photo) : []}
isBusy={isBusy ? isBusy(photo) : false}
/>
))}
</div>
);
}
function PhotoGridTile({
photo,
actions,
badges,
isBusy,
}: {
photo: TenantPhoto;
actions: PhotoGridAction[];
badges: string[];
isBusy: boolean;
}) {
const { border, muted, surfaceMuted } = useAdminTheme();
const overlayBg = withAlpha('#0f172a', 0.55);
const actionBg = withAlpha('#0f172a', 0.55);
return (
<div
style={{
position: 'relative',
borderRadius: 16,
overflow: 'hidden',
border: `1px solid ${border}`,
aspectRatio: '1 / 1',
backgroundColor: surfaceMuted,
opacity: isBusy ? 0.7 : 1,
}}
>
{photo.thumbnail_url ? (
<img
src={photo.thumbnail_url}
alt={photo.original_name ?? 'Photo'}
loading="lazy"
decoding="async"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<XStack alignItems="center" justifyContent="center" height="100%" backgroundColor={surfaceMuted}>
<ImageIcon size={22} color={muted} />
</XStack>
)}
{badges.length ? (
<XStack position="absolute" top={6} left={6} space="$1.5">
{badges.map((label) => (
<PhotoStatusTag key={`${photo.id}-${label}`} label={label} />
))}
</XStack>
) : null}
<XStack
position="absolute"
left={6}
right={6}
bottom={6}
padding="$1"
borderRadius={12}
backgroundColor={overlayBg}
space="$2"
justifyContent="space-between"
>
{actions.map((action) => (
<PhotoActionButton
key={action.key}
label={action.label}
icon={action.icon}
onPress={action.onPress}
disabled={action.disabled}
backgroundColor={action.backgroundColor ?? actionBg}
iconColor={action.iconColor ?? '#fff'}
/>
))}
</XStack>
</div>
);
}
function PhotoActionButton({
label,
icon: Icon,
onPress,
disabled = false,
backgroundColor,
iconColor,
}: {
label: string;
icon: React.ComponentType<{ size?: number; color?: string }>;
onPress: () => void;
disabled?: boolean;
backgroundColor: string;
iconColor: string;
}) {
return (
<Pressable
onPress={disabled ? undefined : onPress}
disabled={disabled}
aria-label={label}
title={label}
style={{ flex: 1, opacity: disabled ? 0.55 : 1 }}
>
<YStack
alignItems="center"
justifyContent="center"
paddingVertical={8}
borderRadius={12}
minHeight={40}
style={{ backgroundColor }}
>
<Icon size={18} color={iconColor} />
</YStack>
</Pressable>
);
}
function PhotoStatusTag({ label }: { label: string }) {
return (
<XStack paddingHorizontal={6} paddingVertical={2} borderRadius={999} backgroundColor={withAlpha('#0f172a', 0.7)}>
<Text fontSize={9} fontWeight="700" color="#fff">
{label}
</Text>
</XStack>
);
}
export default function MobileEventControlRoomPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation('management');
const { activeEvent, selectEvent } = useEventContext();
const { user } = useAuth();
const isMember = user?.role === 'member';
const slug = slugParam ?? activeEvent?.slug ?? null;
const online = useOnlineStatus();
const { textStrong, text, muted, border, accentSoft, accent, danger, primary } = useAdminTheme();
const [activeTab, setActiveTab] = React.useState<'moderation' | 'live'>('moderation');
const [moderationPhotos, setModerationPhotos] = React.useState<TenantPhoto[]>([]);
const [moderationFilter, setModerationFilter] = React.useState<ModerationFilter>('pending');
const [moderationPage, setModerationPage] = React.useState(1);
const [moderationHasMore, setModerationHasMore] = React.useState(false);
const [moderationLoading, setModerationLoading] = React.useState(true);
const [moderationError, setModerationError] = React.useState<string | null>(null);
const [moderationBusyId, setModerationBusyId] = React.useState<number | null>(null);
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [busyScope, setBusyScope] = React.useState<string | null>(null);
const [consentOpen, setConsentOpen] = React.useState(false);
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests'; addonKey: string } | null>(null);
const [consentBusy, setConsentBusy] = React.useState(false);
const [livePhotos, setLivePhotos] = React.useState<TenantPhoto[]>([]);
const [liveStatusFilter, setLiveStatusFilter] = React.useState<LiveShowQueueStatus>('pending');
const [livePage, setLivePage] = React.useState(1);
const [liveHasMore, setLiveHasMore] = React.useState(false);
const [liveLoading, setLiveLoading] = React.useState(true);
const [liveError, setLiveError] = React.useState<string | null>(null);
const [liveBusyId, setLiveBusyId] = React.useState<number | null>(null);
const [queuedActions, setQueuedActions] = React.useState<PhotoModerationAction[]>(() => loadPhotoQueue());
const [syncingQueue, setSyncingQueue] = React.useState(false);
const syncingQueueRef = React.useRef(false);
const moderationResetRef = React.useRef(false);
const liveResetRef = React.useRef(false);
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const infoBg = accentSoft;
const infoBorder = accent;
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam);
}
}, [slugParam, activeEvent?.slug, selectEvent]);
const ensureSlug = React.useCallback(async () => {
if (slug) {
return slug;
}
if (fallbackAttempted) {
return null;
}
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}/control-room`), { replace: true });
return first.slug;
}
} catch {
// ignore
}
return null;
}, [slug, fallbackAttempted, navigate, selectEvent]);
React.useEffect(() => {
setModerationPage(1);
}, [moderationFilter, slug]);
React.useEffect(() => {
setLivePage(1);
}, [liveStatusFilter, slug]);
React.useEffect(() => {
if (activeTab === 'moderation') {
moderationResetRef.current = true;
setModerationPhotos([]);
setModerationPage(1);
} else {
liveResetRef.current = true;
setLivePhotos([]);
setLivePage(1);
}
}, [activeTab]);
const loadModeration = React.useCallback(async () => {
const resolvedSlug = await ensureSlug();
if (!resolvedSlug) {
setModerationLoading(false);
setModerationError(t('events.errors.missingSlug', 'No event selected.'));
return;
}
setModerationLoading(true);
setModerationError(null);
try {
const status =
moderationFilter === 'hidden'
? 'hidden'
: moderationFilter === 'pending'
? 'pending'
: undefined;
const result = await getEventPhotos(resolvedSlug, {
page: moderationPage,
perPage: 20,
sort: 'desc',
featured: moderationFilter === 'featured',
status,
});
setModerationPhotos((prev) => (moderationPage === 1 ? result.photos : [...prev, ...result.photos]));
setLimits(result.limits ?? null);
const lastPage = result.meta?.last_page ?? 1;
setModerationHasMore(moderationPage < lastPage);
const addons = await getAddonCatalog().catch(() => [] as EventAddonCatalogItem[]);
setCatalogAddons(addons ?? []);
} catch (err) {
if (!isAuthError(err)) {
const message = getApiErrorMessage(err, t('mobilePhotos.loadFailed', 'Photos could not be loaded.'));
setModerationError(message);
}
} finally {
setModerationLoading(false);
}
}, [ensureSlug, moderationFilter, moderationPage, t]);
const loadLiveQueue = React.useCallback(async () => {
const resolvedSlug = await ensureSlug();
if (!resolvedSlug) {
setLiveLoading(false);
setLiveError(t('events.errors.missingSlug', 'No event selected.'));
return;
}
setLiveLoading(true);
setLiveError(null);
try {
const result = await getLiveShowQueue(resolvedSlug, {
page: livePage,
perPage: 20,
liveStatus: liveStatusFilter,
});
setLivePhotos((prev) => (livePage === 1 ? result.photos : [...prev, ...result.photos]));
const lastPage = result.meta?.last_page ?? 1;
setLiveHasMore(livePage < lastPage);
} catch (err) {
if (!isAuthError(err)) {
const message = getApiErrorMessage(err, t('liveShowQueue.loadFailed', 'Live Show queue could not be loaded.'));
setLiveError(message);
toast.error(message);
}
} finally {
setLiveLoading(false);
}
}, [ensureSlug, livePage, liveStatusFilter, t]);
React.useEffect(() => {
if (activeTab === 'moderation') {
if (moderationResetRef.current && moderationPage !== 1) {
return;
}
moderationResetRef.current = false;
void loadModeration();
}
}, [activeTab, loadModeration, moderationPage]);
React.useEffect(() => {
if (activeTab === 'live') {
if (liveResetRef.current && livePage !== 1) {
return;
}
liveResetRef.current = false;
void loadLiveQueue();
}
}, [activeTab, loadLiveQueue, livePage]);
React.useEffect(() => {
if (!location.search || !slug) {
return;
}
const params = new URLSearchParams(location.search);
if (params.get('addon_success')) {
toast.success(t('mobileBilling.addonApplied', 'Add-on applied. Limits update shortly.'));
setModerationPage(1);
void loadModeration();
params.delete('addon_success');
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true });
}
}, [location.search, slug, loadModeration, navigate, t, location.pathname]);
const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => {
replacePhotoQueue(queue);
setQueuedActions(queue);
}, []);
const updatePhotoInCollections = React.useCallback((updated: TenantPhoto) => {
setModerationPhotos((prev) => prev.map((photo) => (photo.id === updated.id ? updated : photo)));
setLivePhotos((prev) => prev.map((photo) => (photo.id === updated.id ? updated : photo)));
}, []);
const applyOptimisticUpdate = React.useCallback((photoId: number, action: PhotoModerationAction['action']) => {
const applyUpdate = (photo: TenantPhoto) => {
if (photo.id !== photoId) {
return photo;
}
if (action === 'approve') {
return { ...photo, status: 'approved' };
}
if (action === 'hide') {
return { ...photo, status: 'hidden' };
}
if (action === 'show') {
return { ...photo, status: 'approved' };
}
if (action === 'feature') {
return { ...photo, is_featured: true };
}
if (action === 'unfeature') {
return { ...photo, is_featured: false };
}
return photo;
};
setModerationPhotos((prev) => prev.map(applyUpdate));
setLivePhotos((prev) => prev.map(applyUpdate));
}, []);
const enqueueModerationAction = React.useCallback(
(action: PhotoModerationAction['action'], photoId: number) => {
if (!slug) {
return;
}
const nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId, action });
setQueuedActions(nextQueue);
applyOptimisticUpdate(photoId, action);
toast.success(t('mobilePhotos.queued', 'Action saved. Syncs when you are back online.'));
triggerHaptic('selection');
},
[applyOptimisticUpdate, slug, t],
);
const syncQueuedActions = React.useCallback(async () => {
if (!online || syncingQueueRef.current) {
return;
}
const queue = loadPhotoQueue();
if (queue.length === 0) {
return;
}
syncingQueueRef.current = true;
setSyncingQueue(true);
let remaining = queue;
for (const entry of queue) {
try {
let updated: TenantPhoto | null = null;
if (entry.action === 'approve') {
updated = await updatePhotoStatus(entry.eventSlug, entry.photoId, 'approved');
} else if (entry.action === 'hide') {
updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, true);
} else if (entry.action === 'show') {
updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, false);
} else if (entry.action === 'feature') {
updated = await featurePhoto(entry.eventSlug, entry.photoId);
} else if (entry.action === 'unfeature') {
updated = await unfeaturePhoto(entry.eventSlug, entry.photoId);
}
remaining = removePhotoAction(remaining, entry.id);
if (updated && entry.eventSlug === slug) {
updatePhotoInCollections(updated);
}
} catch (err) {
toast.error(t('mobilePhotos.syncFailed', 'Sync failed. Please try again later.'));
if (isAuthError(err)) {
break;
}
}
}
updateQueueState(remaining);
setSyncingQueue(false);
syncingQueueRef.current = false;
}, [online, slug, t, updatePhotoInCollections, updateQueueState]);
React.useEffect(() => {
if (online) {
void syncQueuedActions();
}
}, [online, syncQueuedActions]);
const handleModerationAction = React.useCallback(
async (action: PhotoModerationAction['action'], photo: TenantPhoto) => {
if (!slug) {
return;
}
if (!online) {
enqueueModerationAction(action, photo.id);
return;
}
setModerationBusyId(photo.id);
try {
let updated: TenantPhoto;
if (action === 'approve') {
updated = await updatePhotoStatus(slug, photo.id, 'approved');
} else if (action === 'hide') {
updated = await updatePhotoVisibility(slug, photo.id, true);
} else if (action === 'show') {
updated = await updatePhotoVisibility(slug, photo.id, false);
} else if (action === 'feature') {
updated = await featurePhoto(slug, photo.id);
} else {
updated = await unfeaturePhoto(slug, photo.id);
}
updatePhotoInCollections(updated);
triggerHaptic(action === 'approve' || action === 'feature' ? 'success' : 'medium');
} catch (err) {
if (!isAuthError(err)) {
const fallbackMessage =
action === 'approve'
? t('mobilePhotos.approveFailed', 'Approval failed.')
: action === 'feature' || action === 'unfeature'
? t('mobilePhotos.featureFailed', 'Highlight could not be changed.')
: t('mobilePhotos.visibilityFailed', 'Visibility could not be changed.');
const message = getApiErrorMessage(err, fallbackMessage);
setModerationError(message);
toast.error(message);
}
} finally {
setModerationBusyId(null);
}
},
[enqueueModerationAction, online, slug, t, updatePhotoInCollections],
);
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
const scope =
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
? scopeOrKey
: scopeOrKey.includes('gallery')
? 'gallery'
: scopeOrKey.includes('guest')
? 'guests'
: 'photos';
const addonKey =
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
? selectAddonKeyForScope(catalogAddons, scope)
: scopeOrKey;
return { scope, addonKey };
}
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
if (!slug) return;
const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey);
setConsentTarget({ scope: scope as 'photos' | 'gallery' | 'guests', addonKey });
setConsentOpen(true);
}
async function confirmAddonCheckout(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) {
if (!slug || !consentTarget) return;
const currentUrl = typeof window !== 'undefined'
? `${window.location.origin}${adminPath(`/mobile/events/${slug}/control-room`)}`
: '';
const successUrl = `${currentUrl}?addon_success=1`;
setBusyScope(consentTarget.scope);
setConsentBusy(true);
try {
const checkout = await createEventAddonCheckout(slug, {
addon_key: consentTarget.addonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
accepted_terms: consents.acceptedTerms,
accepted_waiver: consents.acceptedWaiver,
} as any);
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
} else {
toast.error(t('events.errors.checkoutMissing', 'Checkout could not be started.'));
}
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on checkout failed.')));
} finally {
setConsentBusy(false);
setConsentOpen(false);
setConsentTarget(null);
setBusyScope(null);
}
}
async function handleApprove(photo: TenantPhoto) {
if (!slug || liveBusyId) return;
setLiveBusyId(photo.id);
try {
const updated = await approveLiveShowPhoto(slug, photo.id);
setLivePhotos((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 {
setLiveBusyId(null);
}
}
async function handleApproveAndLive(photo: TenantPhoto) {
if (!slug || liveBusyId) return;
setLiveBusyId(photo.id);
try {
const updated = await approveAndLiveShowPhoto(slug, photo.id);
setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
toast.success(t('liveShowQueue.approveAndLiveSuccess', 'Photo approved and added to Live Show'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
}
} finally {
setLiveBusyId(null);
}
}
async function handleReject(photo: TenantPhoto) {
if (!slug || liveBusyId) return;
setLiveBusyId(photo.id);
try {
const updated = await rejectLiveShowPhoto(slug, photo.id);
setLivePhotos((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 {
setLiveBusyId(null);
}
}
async function handleClear(photo: TenantPhoto) {
if (!slug || liveBusyId) return;
setLiveBusyId(photo.id);
try {
const updated = await clearLiveShowPhoto(slug, photo.id);
setLivePhotos((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 {
setLiveBusyId(null);
}
}
function resolveGalleryLabel(status?: string | null): string {
const key = status ?? 'pending';
const fallbackMap: Record<string, string> = {
approved: 'Gallery approved',
pending: 'Gallery pending',
rejected: 'Gallery rejected',
hidden: 'Hidden',
};
return t(`liveShowQueue.galleryStatus.${key}`, fallbackMap[key] ?? key);
}
function resolveLiveLabel(status?: string | null): string {
const key = normalizeLiveStatus(status);
return t(`liveShowQueue.status.${key}`, key);
}
const queuedEventCount = React.useMemo(() => {
if (!slug) {
return queuedActions.length;
}
return queuedActions.filter((action) => action.eventSlug === slug).length;
}, [queuedActions, slug]);
const headerActions = (
<XStack space="$2">
<HeaderActionButton
onPress={() => {
if (activeTab === 'moderation') {
void loadModeration();
return;
}
void loadLiveQueue();
}}
ariaLabel={t('common.refresh', 'Refresh')}
>
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
{slug && !isMember ? (
<HeaderActionButton
onPress={() => navigate(adminPath(`/mobile/events/${slug}/live-show/settings`))}
ariaLabel={t('events.quick.liveShowSettings', 'Live Show settings')}
>
<Settings size={18} color={textStrong} />
</HeaderActionButton>
) : null}
</XStack>
);
return (
<MobileShell
activeTab="uploads"
title={t('controlRoom.title', 'Moderation & Live Show')}
subtitle={t('controlRoom.subtitle', 'Review uploads and manage the live slideshow.')}
onBack={back}
headerActions={headerActions}
>
<XStack space="$2">
{([
{ key: 'moderation', label: t('controlRoom.tabs.moderation', 'Moderation') },
{ key: 'live', label: t('controlRoom.tabs.live', 'Live Show') },
] as const).map((tab) => (
<Pressable key={tab.key} onPress={() => setActiveTab(tab.key)} style={{ flex: 1 }}>
<MobileCard
backgroundColor={activeTab === tab.key ? infoBg : 'transparent'}
borderColor={activeTab === tab.key ? infoBorder : border}
padding="$2.5"
>
<Text fontSize="$sm" fontWeight="700" textAlign="center" color={textStrong}>
{tab.label}
</Text>
</MobileCard>
</Pressable>
))}
</XStack>
{activeTab === 'moderation' ? (
<YStack space="$2">
{queuedEventCount > 0 ? (
<MobileCard>
<XStack alignItems="center" justifyContent="space-between" space="$2">
<YStack space="$1" flex={1}>
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobilePhotos.queueTitle', 'Changes waiting to sync')}
</Text>
<Text fontSize="$xs" color={muted}>
{online
? t('mobilePhotos.queueOnline', '{{count}} actions ready to sync.', { count: queuedEventCount })
: t('mobilePhotos.queueOffline', '{{count}} actions saved offline.', { count: queuedEventCount })}
</Text>
</YStack>
<CTAButton
label={online ? t('mobilePhotos.queueSync', 'Sync') : t('mobilePhotos.queueWaiting', 'Offline')}
onPress={() => syncQueuedActions()}
tone="ghost"
fullWidth={false}
disabled={!online}
loading={syncingQueue}
/>
</XStack>
</MobileCard>
) : null}
<MobileCard>
<MobileField label={t('mobilePhotos.filtersTitle', 'Filter')}>
<MobileSelect
value={moderationFilter}
onChange={(event) => setModerationFilter(event.target.value as ModerationFilter)}
>
{MODERATION_FILTERS.map((option) => (
<option key={option.value} value={option.value}>
{t(option.labelKey, option.fallback)}
</option>
))}
</MobileSelect>
</MobileField>
</MobileCard>
{!moderationLoading ? (
<LimitWarnings
limits={limits}
addons={catalogAddons}
onCheckout={startAddonCheckout}
busyScope={busyScope}
translate={translateLimits(t as any)}
textColor={text}
borderColor={border}
/>
) : null}
{moderationError ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{moderationError}
</Text>
</MobileCard>
) : null}
{moderationLoading && moderationPage === 1 ? (
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<SkeletonCard key={`moderation-skeleton-${idx}`} height={120} />
))}
</YStack>
) : moderationPhotos.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<ImageIcon size={28} color={muted} />
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('controlRoom.emptyModeration', 'No uploads match this filter.')}
</Text>
</MobileCard>
) : (
<PhotoGrid
photos={moderationPhotos}
isBusy={(photo) => moderationBusyId === photo.id}
badgesForPhoto={(photo) => {
const badges: string[] = [];
if (photo.status === 'hidden') {
badges.push(t('photos.filters.hidden', 'Hidden'));
}
if (photo.is_featured) {
badges.push(t('photos.filters.featured', 'Highlights'));
}
return badges;
}}
actionsForPhoto={(photo) => {
const isBusy = moderationBusyId === photo.id;
const galleryStatus = photo.status ?? 'pending';
const canApprove = galleryStatus === 'pending';
const canShow = galleryStatus === 'hidden';
const visibilityAction: PhotoModerationAction['action'] = canShow ? 'show' : 'hide';
const visibilityLabel = canShow
? t('photos.actions.show', 'Show')
: t('photos.actions.hide', 'Hide');
const featureAction: PhotoModerationAction['action'] = photo.is_featured ? 'unfeature' : 'feature';
const featureLabel = photo.is_featured
? t('photos.actions.unfeature', 'Remove highlight')
: t('photos.actions.feature', 'Set highlight');
const approveBg = withAlpha(primary, 0.78);
const hideBg = withAlpha(danger, 0.75);
const highlightBg = photo.is_featured ? withAlpha(accent, 0.85) : withAlpha(accent, 0.55);
return [
{
key: 'approve',
label: t('photos.actions.approve', 'Approve'),
icon: Check,
onPress: () => handleModerationAction('approve', photo),
disabled: !canApprove || isBusy,
backgroundColor: approveBg,
},
{
key: 'visibility',
label: visibilityLabel,
icon: canShow ? Eye : EyeOff,
onPress: () => handleModerationAction(visibilityAction, photo),
disabled: isBusy,
backgroundColor: hideBg,
},
{
key: 'feature',
label: featureLabel,
icon: Sparkles,
onPress: () => handleModerationAction(featureAction, photo),
disabled: isBusy,
backgroundColor: highlightBg,
},
];
}}
/>
)}
{moderationHasMore ? (
<MobileCard>
<CTAButton
label={t('common.loadMore', 'Load more')}
onPress={() => setModerationPage((prev) => prev + 1)}
disabled={moderationLoading}
/>
</MobileCard>
) : null}
</YStack>
) : (
<YStack space="$2">
<MobileCard borderColor={border} backgroundColor="transparent">
<Text fontSize="$sm" color={muted}>
{t(
'liveShowQueue.galleryApprovedOnly',
'Gallery and Live Show approvals are separate. Pending photos can be approved here.'
)}
</Text>
{!online ? (
<Text fontSize="$sm" color={danger}>
{t('liveShowQueue.offlineNotice', 'You are offline. Live Show actions are disabled.')}
</Text>
) : null}
</MobileCard>
<MobileCard>
<MobileField label={t('liveShowQueue.filterLabel', 'Live status')}>
<MobileSelect
value={liveStatusFilter}
onChange={(event) => setLiveStatusFilter(event.target.value as LiveShowQueueStatus)}
>
{LIVE_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{t(option.labelKey, option.fallback)}
</option>
))}
</MobileSelect>
</MobileField>
</MobileCard>
{liveError ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{liveError}
</Text>
</MobileCard>
) : null}
{liveLoading && livePage === 1 ? (
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<SkeletonCard key={`live-skeleton-${idx}`} height={120} />
))}
</YStack>
) : livePhotos.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('controlRoom.emptyLive', 'No photos waiting for Live Show.')}
</Text>
</MobileCard>
) : (
<PhotoGrid
photos={livePhotos}
isBusy={(photo) => moderationBusyId === photo.id || liveBusyId === photo.id}
badgesForPhoto={(photo) => {
const badges: string[] = [];
const liveStatus = normalizeLiveStatus(photo.live_status);
if (liveStatus !== 'pending') {
badges.push(resolveLiveLabel(liveStatus));
}
if (photo.status === 'hidden') {
badges.push(t('photos.filters.hidden', 'Hidden'));
}
if (photo.is_featured) {
badges.push(t('photos.filters.featured', 'Highlights'));
}
return badges;
}}
actionsForPhoto={(photo) => {
const isLiveBusy = liveBusyId === photo.id;
const isModerationBusy = moderationBusyId === photo.id;
const liveStatus = normalizeLiveStatus(photo.live_status);
const galleryStatus = photo.status ?? 'pending';
const approveMode = resolveLiveShowApproveMode(galleryStatus);
const canApproveLive = approveMode !== 'not-eligible';
const approveDisabled = !online || !canApproveLive || liveStatus === 'approved' || isLiveBusy;
const visibilityLabel = t('photos.actions.hide', 'Hide');
const featureAction: PhotoModerationAction['action'] = photo.is_featured ? 'unfeature' : 'feature';
const featureLabel = photo.is_featured
? t('photos.actions.unfeature', 'Remove highlight')
: t('photos.actions.feature', 'Set highlight');
const approveBg = withAlpha(primary, 0.78);
const hideBg = withAlpha(danger, 0.75);
const highlightBg = photo.is_featured ? withAlpha(accent, 0.85) : withAlpha(accent, 0.55);
return [
{
key: 'approve',
label: t('photos.actions.approve', 'Approve'),
icon: Check,
onPress: () => {
if (approveMode === 'approve-and-live') {
void handleApproveAndLive(photo);
return;
}
if (approveMode === 'approve-only') {
void handleApprove(photo);
}
},
disabled: approveDisabled,
backgroundColor: approveBg,
},
{
key: 'visibility',
label: visibilityLabel,
icon: EyeOff,
onPress: () => {
if (liveStatus === 'approved' || liveStatus === 'rejected') {
void handleClear(photo);
return;
}
void handleReject(photo);
},
disabled: !online || isLiveBusy || isModerationBusy,
backgroundColor: hideBg,
},
{
key: 'feature',
label: featureLabel,
icon: Sparkles,
onPress: () => handleModerationAction(featureAction, photo),
disabled: isModerationBusy,
backgroundColor: highlightBg,
},
];
}}
/>
)}
{liveHasMore ? (
<MobileCard>
<CTAButton
label={t('common.loadMore', 'Load more')}
onPress={() => setLivePage((prev) => prev + 1)}
disabled={liveLoading}
/>
</MobileCard>
) : null}
</YStack>
)}
<LegalConsentSheet
open={consentOpen}
onClose={() => {
if (consentBusy) return;
setConsentOpen(false);
setConsentTarget(null);
}}
onConfirm={confirmAddonCheckout}
busy={consentBusy}
t={t}
/>
</MobileShell>
);
}