1569 lines
56 KiB
TypeScript
1569 lines
56 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 { Switch } from '@tamagui/switch';
|
|
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,
|
|
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, accentSoft, accent, danger, primary, surfaceMuted } = 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 [controlRoomSettings, setControlRoomSettings] = React.useState<ControlRoomSettings>({
|
|
auto_approve_highlights: false,
|
|
auto_add_approved_to_live: false,
|
|
auto_remove_live_on_hide: false,
|
|
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 infoBg = accentSoft;
|
|
const infoBorder = accent;
|
|
|
|
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;
|
|
setControlRoomSettings({
|
|
auto_approve_highlights: Boolean(settings.auto_approve_highlights),
|
|
auto_add_approved_to_live: Boolean(settings.auto_add_approved_to_live),
|
|
auto_remove_live_on_hide: Boolean(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 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 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);
|
|
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)));
|
|
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>
|
|
|
|
<MobileCard>
|
|
<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>
|
|
</MobileCard>
|
|
|
|
<MobileCard>
|
|
<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.',
|
|
)}
|
|
>
|
|
<XStack alignItems="center" space="$2">
|
|
<MobileSelect
|
|
value={trustedUploaderSelection}
|
|
onChange={(event) => setTrustedUploaderSelection(event.target.value)}
|
|
style={{ flex: 1 }}
|
|
>
|
|
<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"
|
|
fullWidth={false}
|
|
disabled={controlRoomSaving || !trustedUploaderSelection}
|
|
style={{ width: 100 }}
|
|
/>
|
|
</XStack>
|
|
{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.',
|
|
)}
|
|
>
|
|
<XStack alignItems="center" space="$2">
|
|
<MobileSelect
|
|
value={forceReviewSelection}
|
|
onChange={(event) => setForceReviewSelection(event.target.value)}
|
|
style={{ flex: 1 }}
|
|
>
|
|
<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"
|
|
fullWidth={false}
|
|
disabled={controlRoomSaving || !forceReviewSelection}
|
|
style={{ width: 100 }}
|
|
/>
|
|
</XStack>
|
|
{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>
|
|
</MobileCard>
|
|
|
|
{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>
|
|
);
|
|
}
|