Files
fotospiel-app/resources/js/admin/mobile/EventControlRoomPage.tsx
Codex Agent 9d3c866562
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Fix: Add missing 'text' variable to EventControlRoomPage theme destructuring
2026-01-22 13:35:09 +01:00

1748 lines
65 KiB
TypeScript

import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Check, ChevronDown, 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 { Switch } from '@tamagui/switch';
import { Accordion } from '@tamagui/accordion';
import { ScrollView } from '@tamagui/scroll-view';
import { ToggleGroup } from '@tamagui/toggle-group';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard, ContentTabs } from './components/Primitives';
import { MobileField, MobileSelect } from './components/FormControls';
import { useEventContext } from '../context/EventContext';
import {
approveAndLiveShowPhoto,
approveLiveShowPhoto,
clearLiveShowPhoto,
ControlRoomSettings,
ControlRoomUploaderRule,
createEventAddonCheckout,
EventAddonCatalogItem,
EventLimitSummary,
getAddonCatalog,
featurePhoto,
getEventPhotos,
getEvents,
getLiveShowQueue,
LiveShowQueueStatus,
rejectLiveShowPhoto,
TenantEvent,
TenantPhoto,
unfeaturePhoto,
updatePhotoStatus,
updateEvent,
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>
);
}
function formatDeviceId(deviceId: string): string {
if (!deviceId) {
return '';
}
if (deviceId.length <= 10) {
return deviceId;
}
return `${deviceId.slice(0, 4)}...${deviceId.slice(-4)}`;
}
export default function MobileEventControlRoomPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation('management');
const { activeEvent, selectEvent, refetch } = useEventContext();
const { user } = useAuth();
const isMember = user?.role === 'member';
const slug = slugParam ?? activeEvent?.slug ?? null;
const online = useOnlineStatus();
const { textStrong, text, muted, border, primary, surfaceMuted, surface } = 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 [moderationCounts, setModerationCounts] = React.useState<Record<ModerationFilter, number>>({
all: 0,
pending: 0,
featured: 0,
hidden: 0,
});
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 [liveCounts, setLiveCounts] = React.useState<Record<LiveShowQueueStatus, number>>({
pending: 0,
approved: 0,
rejected: 0,
none: 0,
all: 0,
expired: 0,
});
const [controlRoomSettings, setControlRoomSettings] = React.useState<ControlRoomSettings>({
auto_approve_highlights: true,
auto_add_approved_to_live: true,
auto_remove_live_on_hide: true,
trusted_uploaders: [],
force_review_uploaders: [],
});
const [controlRoomSaving, setControlRoomSaving] = React.useState(false);
const [trustedUploaderSelection, setTrustedUploaderSelection] = React.useState('');
const [forceReviewSelection, setForceReviewSelection] = React.useState('');
const isImmediateUploads = (activeEvent?.settings?.guest_upload_visibility ?? 'review') === 'immediate';
const autoApproveHighlights = Boolean(controlRoomSettings.auto_approve_highlights);
const autoAddApprovedToLive = Boolean(controlRoomSettings.auto_add_approved_to_live) || isImmediateUploads;
const autoRemoveLiveOnHide = Boolean(controlRoomSettings.auto_remove_live_on_hide);
const trustedUploaders = controlRoomSettings.trusted_uploaders ?? [];
const forceReviewUploaders = controlRoomSettings.force_review_uploaders ?? [];
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 activeFilterBg = primary;
const saveControlRoomSettings = React.useCallback(
async (nextSettings: ControlRoomSettings) => {
if (!slug) {
return;
}
const previous = controlRoomSettings;
setControlRoomSettings(nextSettings);
setControlRoomSaving(true);
try {
await updateEvent(slug, {
settings: {
control_room: nextSettings,
},
});
refetch();
} catch (err) {
setControlRoomSettings(previous);
toast.error(
getApiErrorMessage(
err,
t('controlRoom.automation.saveFailed', 'Automation settings could not be saved.')
)
);
} finally {
setControlRoomSaving(false);
}
},
[controlRoomSettings, refetch, slug, t],
);
const uploaderOptions = React.useMemo(() => {
const options = new Map<string, { deviceId: string; label: string }>();
const addPhoto = (photo: TenantPhoto) => {
const deviceId = photo.created_by_device_id ?? '';
if (!deviceId) {
return;
}
if (options.has(deviceId)) {
return;
}
options.set(deviceId, {
deviceId,
label: photo.uploader_name ?? t('common.anonymous', 'Anonymous'),
});
};
moderationPhotos.forEach(addPhoto);
livePhotos.forEach(addPhoto);
return Array.from(options.values()).map((entry) => ({
...entry,
display: `${entry.label}${formatDeviceId(entry.deviceId)}`,
}));
}, [livePhotos, moderationPhotos, t]);
const mergeUploaderRule = (list: ControlRoomUploaderRule[], rule: ControlRoomUploaderRule) => {
if (list.some((entry) => entry.device_id === rule.device_id)) {
return list;
}
return [...list, rule];
};
const addUploaderRule = React.useCallback(
(target: 'trusted' | 'force') => {
const selectedId = target === 'trusted' ? trustedUploaderSelection : forceReviewSelection;
if (!selectedId) {
return;
}
const option = uploaderOptions.find((entry) => entry.deviceId === selectedId);
const rule: ControlRoomUploaderRule = {
device_id: selectedId,
label: option?.label ?? t('common.anonymous', 'Anonymous'),
};
const currentTrusted = controlRoomSettings.trusted_uploaders ?? [];
const currentForceReview = controlRoomSettings.force_review_uploaders ?? [];
const nextTrusted =
target === 'trusted'
? mergeUploaderRule(currentTrusted, rule)
: currentTrusted.filter((entry) => entry.device_id !== selectedId);
const nextForceReview =
target === 'force'
? mergeUploaderRule(currentForceReview, rule)
: currentForceReview.filter((entry) => entry.device_id !== selectedId);
saveControlRoomSettings({
...controlRoomSettings,
trusted_uploaders: nextTrusted,
force_review_uploaders: nextForceReview,
});
setTrustedUploaderSelection('');
setForceReviewSelection('');
},
[
controlRoomSettings,
forceReviewSelection,
saveControlRoomSettings,
t,
trustedUploaderSelection,
uploaderOptions,
],
);
const removeUploaderRule = React.useCallback(
(target: 'trusted' | 'force', deviceId: string) => {
const nextTrusted =
target === 'trusted'
? (controlRoomSettings.trusted_uploaders ?? []).filter((entry) => entry.device_id !== deviceId)
: controlRoomSettings.trusted_uploaders ?? [];
const nextForceReview =
target === 'force'
? (controlRoomSettings.force_review_uploaders ?? []).filter((entry) => entry.device_id !== deviceId)
: controlRoomSettings.force_review_uploaders ?? [];
saveControlRoomSettings({
...controlRoomSettings,
trusted_uploaders: nextTrusted,
force_review_uploaders: nextForceReview,
});
},
[controlRoomSettings, saveControlRoomSettings],
);
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam);
}
}, [slugParam, activeEvent?.slug, selectEvent]);
React.useEffect(() => {
const settings = (activeEvent?.settings?.control_room ?? {}) as ControlRoomSettings;
const resolveFlag = (value: boolean | undefined) => (typeof value === 'boolean' ? value : true);
setControlRoomSettings({
auto_approve_highlights: resolveFlag(settings.auto_approve_highlights),
auto_add_approved_to_live: resolveFlag(settings.auto_add_approved_to_live),
auto_remove_live_on_hide: resolveFlag(settings.auto_remove_live_on_hide),
trusted_uploaders: Array.isArray(settings.trusted_uploaders) ? settings.trusted_uploaders : [],
force_review_uploaders: Array.isArray(settings.force_review_uploaders) ? settings.force_review_uploaders : [],
});
setTrustedUploaderSelection('');
setForceReviewSelection('');
}, [activeEvent?.settings?.control_room, activeEvent?.slug]);
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 loadModerationCounts = React.useCallback(async () => {
if (!slug) {
return;
}
try {
const [all, pending, featured, hidden] = await Promise.all([
getEventPhotos(slug, { page: 1, perPage: 1, sort: 'desc' }),
getEventPhotos(slug, { page: 1, perPage: 1, sort: 'desc', status: 'pending' }),
getEventPhotos(slug, { page: 1, perPage: 1, sort: 'desc', featured: true }),
getEventPhotos(slug, { page: 1, perPage: 1, sort: 'desc', status: 'hidden' }),
]);
setModerationCounts({
all: all.meta?.total ?? all.photos.length,
pending: pending.meta?.total ?? pending.photos.length,
featured: featured.meta?.total ?? featured.photos.length,
hidden: hidden.meta?.total ?? hidden.photos.length,
});
} catch {
// ignore
}
}, [slug]);
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]);
const loadLiveCounts = React.useCallback(async () => {
if (!slug) {
return;
}
try {
const statuses: LiveShowQueueStatus[] = ['pending', 'approved', 'rejected', 'none'];
const results = await Promise.all(
statuses.map((status) => getLiveShowQueue(slug, { page: 1, perPage: 1, liveStatus: status }))
);
const nextCounts: Record<LiveShowQueueStatus, number> = {
pending: results[0]?.meta?.total ?? results[0]?.photos.length ?? 0,
approved: results[1]?.meta?.total ?? results[1]?.photos.length ?? 0,
rejected: results[2]?.meta?.total ?? results[2]?.photos.length ?? 0,
none: results[3]?.meta?.total ?? results[3]?.photos.length ?? 0,
all: 0,
expired: 0,
};
setLiveCounts(nextCounts);
} catch {
// ignore
}
}, [slug]);
const refreshCounts = React.useCallback(() => {
void loadModerationCounts();
void loadLiveCounts();
}, [loadLiveCounts, loadModerationCounts]);
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(() => {
refreshCounts();
}, [refreshCounts]);
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 enqueueModerationActions = React.useCallback(
(actions: PhotoModerationAction['action'][], photoId: number) => {
if (!slug) {
return;
}
let nextQueue: PhotoModerationAction[] = [];
actions.forEach((action) => {
nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId, action });
applyOptimisticUpdate(photoId, action);
});
setQueuedActions(nextQueue);
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;
}
const shouldAutoApproveHighlight = action === 'feature' && autoApproveHighlights && photo.status === 'pending';
if (!online) {
if (shouldAutoApproveHighlight) {
enqueueModerationActions(['approve', 'feature'], photo.id);
return;
}
enqueueModerationAction(action, photo.id);
return;
}
setModerationBusyId(photo.id);
try {
let updated: TenantPhoto;
if (action === 'approve') {
updated = autoAddApprovedToLive
? await approveAndLiveShowPhoto(slug, photo.id)
: 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') {
if (shouldAutoApproveHighlight) {
if (autoAddApprovedToLive) {
await approveAndLiveShowPhoto(slug, photo.id);
} else {
await updatePhotoStatus(slug, photo.id, 'approved');
}
}
updated = await featurePhoto(slug, photo.id);
} else {
updated = await unfeaturePhoto(slug, photo.id);
}
updatePhotoInCollections(updated);
refreshCounts();
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);
}
},
[
autoAddApprovedToLive,
autoApproveHighlights,
enqueueModerationAction,
enqueueModerationActions,
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)));
refreshCounts();
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)));
refreshCounts();
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)));
refreshCounts();
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)));
refreshCounts();
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 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}
>
<ContentTabs
value={activeTab}
onValueChange={(val) => setActiveTab(val as 'moderation' | 'live')}
header={(
<MobileCard>
<Accordion type="single" collapsible>
<Accordion.Item value="upload-settings">
<Accordion.Trigger
{...({
padding: '$2',
borderWidth: 1,
borderColor: border,
borderRadius: 14,
backgroundColor: surfaceMuted,
} as any)}
>
<XStack justifyContent="space-between" alignItems="center" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('controlRoom.automation.sectionTitle', 'Einstellungen für Uploads')}
</Text>
<ChevronDown size={16} color={muted} />
</XStack>
</Accordion.Trigger>
<Accordion.Content {...({ paddingTop: '$2' } as any)}>
<YStack space="$3">
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('controlRoom.automation.title', 'Automation')}
</Text>
<MobileField
label={t('controlRoom.automation.autoApproveHighlights.label', 'Auto-approve highlights')}
hint={t(
'controlRoom.automation.autoApproveHighlights.help',
'When you highlight a pending photo it will be approved automatically.',
)}
>
<Switch
size="$3"
checked={autoApproveHighlights}
disabled={controlRoomSaving}
onCheckedChange={(checked) =>
saveControlRoomSettings({
...controlRoomSettings,
auto_approve_highlights: Boolean(checked),
})
}
aria-label={t('controlRoom.automation.autoApproveHighlights.label', 'Auto-approve highlights')}
>
<Switch.Thumb />
</Switch>
</MobileField>
<MobileField
label={t('controlRoom.automation.autoAddApproved.label', 'Auto-add approved to Live Show')}
hint={
isImmediateUploads
? t(
'controlRoom.automation.autoAddApproved.locked',
'Enabled because uploads are visible immediately.',
)
: t(
'controlRoom.automation.autoAddApproved.help',
'Approved or highlighted photos go straight to the Live Show.',
)
}
>
<Switch
size="$3"
checked={autoAddApprovedToLive}
disabled={controlRoomSaving || isImmediateUploads}
onCheckedChange={(checked) => {
if (isImmediateUploads) {
return;
}
saveControlRoomSettings({
...controlRoomSettings,
auto_add_approved_to_live: Boolean(checked),
});
}}
aria-label={t('controlRoom.automation.autoAddApproved.label', 'Auto-add approved to Live Show')}
>
<Switch.Thumb />
</Switch>
</MobileField>
<MobileField
label={t('controlRoom.automation.autoRemoveLive.label', 'Auto-remove from Live Show')}
hint={t(
'controlRoom.automation.autoRemoveLive.help',
'Hidden or rejected photos are removed from the Live Show automatically.',
)}
>
<Switch
size="$3"
checked={autoRemoveLiveOnHide}
disabled={controlRoomSaving}
onCheckedChange={(checked) =>
saveControlRoomSettings({
...controlRoomSettings,
auto_remove_live_on_hide: Boolean(checked),
})
}
aria-label={t('controlRoom.automation.autoRemoveLive.label', 'Auto-remove from Live Show')}
>
<Switch.Thumb />
</Switch>
</MobileField>
</YStack>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('controlRoom.automation.uploaders.title', 'Uploader overrides')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'controlRoom.automation.uploaders.subtitle',
'Pick devices from recent uploads to always approve or always review their photos.',
)}
</Text>
<MobileField
label={t('controlRoom.automation.uploaders.trustedLabel', 'Always approve')}
hint={t(
'controlRoom.automation.uploaders.trustedHelp',
'Uploads from these devices skip the approval queue.',
)}
>
<YStack space="$2">
<MobileSelect
value={trustedUploaderSelection}
onChange={(event) => setTrustedUploaderSelection(event.target.value)}
>
<option value="">{t('common.select', 'Select')}</option>
{uploaderOptions.map((option) => (
<option key={option.deviceId} value={option.deviceId}>
{option.display}
</option>
))}
</MobileSelect>
<CTAButton
label={t('controlRoom.automation.uploaders.add', 'Add')}
onPress={() => addUploaderRule('trusted')}
tone="ghost"
disabled={controlRoomSaving || !trustedUploaderSelection}
/>
</YStack>
{trustedUploaders.length ? (
<YStack space="$2">
{trustedUploaders.map((rule) => (
<XStack
key={`trusted-${rule.device_id}`}
alignItems="center"
justifyContent="space-between"
padding="$2"
borderRadius={14}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<YStack space="$0.5">
<Text fontSize="$sm" fontWeight="700" color={text}>
{rule.label ?? t('common.anonymous', 'Anonymous')}
</Text>
<Text fontSize="$xs" color={muted}>
{formatDeviceId(rule.device_id)}
</Text>
</YStack>
<Pressable
onPress={() => removeUploaderRule('trusted', rule.device_id)}
disabled={controlRoomSaving}
aria-label={t('controlRoom.automation.uploaders.remove', 'Remove')}
style={{
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: withAlpha(danger, 0.12),
}}
>
<Text fontSize="$xs" fontWeight="700" color={danger}>
{t('controlRoom.automation.uploaders.remove', 'Remove')}
</Text>
</Pressable>
</XStack>
))}
</YStack>
) : (
<Text fontSize="$xs" color={muted}>
{t('controlRoom.automation.uploaders.empty', 'No overrides yet.')}
</Text>
)}
</MobileField>
<MobileField
label={t('controlRoom.automation.uploaders.forceLabel', 'Always review')}
hint={t(
'controlRoom.automation.uploaders.forceHelp',
'Uploads from these devices always need approval.',
)}
>
<YStack space="$2">
<MobileSelect
value={forceReviewSelection}
onChange={(event) => setForceReviewSelection(event.target.value)}
>
<option value="">{t('common.select', 'Select')}</option>
{uploaderOptions.map((option) => (
<option key={option.deviceId} value={option.deviceId}>
{option.display}
</option>
))}
</MobileSelect>
<CTAButton
label={t('controlRoom.automation.uploaders.add', 'Add')}
onPress={() => addUploaderRule('force')}
tone="ghost"
disabled={controlRoomSaving || !forceReviewSelection}
/>
</YStack>
{forceReviewUploaders.length ? (
<YStack space="$2">
{forceReviewUploaders.map((rule) => (
<XStack
key={`force-${rule.device_id}`}
alignItems="center"
justifyContent="space-between"
padding="$2"
borderRadius={14}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<YStack space="$0.5">
<Text fontSize="$sm" fontWeight="700" color={text}>
{rule.label ?? t('common.anonymous', 'Anonymous')}
</Text>
<Text fontSize="$xs" color={muted}>
{formatDeviceId(rule.device_id)}
</Text>
</YStack>
<Pressable
onPress={() => removeUploaderRule('force', rule.device_id)}
disabled={controlRoomSaving}
aria-label={t('controlRoom.automation.uploaders.remove', 'Remove')}
style={{
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: withAlpha(danger, 0.12),
}}
>
<Text fontSize="$xs" fontWeight="700" color={danger}>
{t('controlRoom.automation.uploaders.remove', 'Remove')}
</Text>
</Pressable>
</XStack>
))}
</YStack>
) : (
<Text fontSize="$xs" color={muted}>
{t('controlRoom.automation.uploaders.empty', 'No overrides yet.')}
</Text>
)}
</MobileField>
</YStack>
</YStack>
</Accordion.Content>
</Accordion.Item>
</Accordion>
</MobileCard>
)}
tabs={[
{
value: 'moderation',
label: t('controlRoom.tabs.moderation', 'Moderation'),
content: (
<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>
<YStack space="$2">
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('mobilePhotos.filtersTitle', 'Filter')}
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<XStack
alignItems="center"
padding="$1"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
overflow="hidden"
>
<ToggleGroup
type="single"
value={moderationFilter}
onValueChange={(value) => value && setModerationFilter(value as ModerationFilter)}
>
<XStack space="$1.5">
{MODERATION_FILTERS.map((option) => {
const active = option.value === moderationFilter;
const count = moderationCounts[option.value] ?? 0;
return (
<ToggleGroup.Item
key={option.value}
value={option.value}
unstyled
borderRadius={999}
borderWidth={1}
borderColor="transparent"
backgroundColor={active ? activeFilterBg : 'transparent'}
paddingVertical="$1.5"
paddingHorizontal="$3"
pressStyle={{ opacity: 0.85 }}
>
<XStack alignItems="center" space="$1.5">
<Text fontSize="$xs" fontWeight="700" color={active ? '#fff' : muted}>
{t(option.labelKey, option.fallback)}
</Text>
<XStack
paddingHorizontal="$1.5"
paddingVertical="$0.5"
borderRadius={999}
borderWidth={1}
borderColor={active ? 'transparent' : border}
backgroundColor={active ? withAlpha('#ffffff', 0.2) : surface}
>
<Text fontSize={10} fontWeight="800" color={active ? '#fff' : muted}>
{count}
</Text>
</XStack>
</XStack>
</ToggleGroup.Item>
);
})}
</XStack>
</ToggleGroup>
</XStack>
</ScrollView>
</YStack>
</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>
),
},
{
value: 'live',
label: t('controlRoom.tabs.live', 'Live Show'),
content: (
<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>
<YStack space="$2">
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('liveShowQueue.filterLabel', 'Live status')}
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<XStack
alignItems="center"
padding="$1"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
overflow="hidden"
>
<ToggleGroup
type="single"
value={liveStatusFilter}
onValueChange={(value) => value && setLiveStatusFilter(value as LiveShowQueueStatus)}
>
<XStack space="$1.5">
{LIVE_STATUS_OPTIONS.map((option) => {
const active = option.value === liveStatusFilter;
const count = liveCounts[option.value] ?? 0;
return (
<ToggleGroup.Item
key={option.value}
value={option.value}
unstyled
borderRadius={999}
borderWidth={1}
borderColor="transparent"
backgroundColor={active ? activeFilterBg : 'transparent'}
paddingVertical="$1.5"
paddingHorizontal="$3"
pressStyle={{ opacity: 0.85 }}
>
<XStack alignItems="center" space="$1.5">
<Text fontSize="$xs" fontWeight="700" color={active ? '#fff' : muted}>
{t(option.labelKey, option.fallback)}
</Text>
<XStack
paddingHorizontal="$1.5"
paddingVertical="$0.5"
borderRadius={999}
borderWidth={1}
borderColor={active ? 'transparent' : border}
backgroundColor={active ? withAlpha('#ffffff', 0.2) : surface}
>
<Text fontSize={10} fontWeight="800" color={active ? '#fff' : muted}>
{count}
</Text>
</XStack>
</XStack>
</ToggleGroup.Item>
);
})}
</XStack>
</ToggleGroup>
</XStack>
</ScrollView>
</YStack>
</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>
);
}