Add control room automations and uploader overrides
This commit is contained in:
@@ -73,6 +73,19 @@ export type LiveShowSettings = {
|
||||
background_mode?: 'blur_last' | 'gradient' | 'solid' | 'brand';
|
||||
};
|
||||
|
||||
export type ControlRoomUploaderRule = {
|
||||
device_id: string;
|
||||
label?: string | null;
|
||||
};
|
||||
|
||||
export type ControlRoomSettings = {
|
||||
auto_approve_highlights?: boolean;
|
||||
auto_add_approved_to_live?: boolean;
|
||||
auto_remove_live_on_hide?: boolean;
|
||||
trusted_uploaders?: ControlRoomUploaderRule[];
|
||||
force_review_uploaders?: ControlRoomUploaderRule[];
|
||||
};
|
||||
|
||||
export type LiveShowLink = {
|
||||
token: string;
|
||||
url: string;
|
||||
@@ -101,6 +114,7 @@ export type TenantEvent = {
|
||||
engagement_mode?: 'tasks' | 'photo_only';
|
||||
guest_upload_visibility?: 'review' | 'immediate';
|
||||
live_show?: LiveShowSettings;
|
||||
control_room?: ControlRoomSettings;
|
||||
watermark?: WatermarkSettings;
|
||||
watermark_allowed?: boolean | null;
|
||||
watermark_removal_allowed?: boolean | null;
|
||||
@@ -164,6 +178,7 @@ export type TenantPhoto = {
|
||||
likes_count: number;
|
||||
uploaded_at: string;
|
||||
uploader_name: string | null;
|
||||
created_by_device_id?: string | null;
|
||||
ingest_source?: string | null;
|
||||
caption?: string | null;
|
||||
};
|
||||
@@ -809,6 +824,7 @@ type EventSavePayload = {
|
||||
accepted_waiver?: boolean;
|
||||
settings?: Record<string, unknown> & {
|
||||
live_show?: LiveShowSettings;
|
||||
control_room?: ControlRoomSettings;
|
||||
watermark?: WatermarkSettings;
|
||||
watermark_serve_originals?: boolean | null;
|
||||
};
|
||||
@@ -1022,6 +1038,7 @@ function normalizePhoto(photo: TenantPhoto): TenantPhoto {
|
||||
likes_count: Number(photo.likes_count ?? 0),
|
||||
uploaded_at: photo.uploaded_at,
|
||||
uploader_name: photo.uploader_name ?? null,
|
||||
created_by_device_id: photo.created_by_device_id ?? null,
|
||||
caption: photo.caption ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2482,6 +2482,34 @@
|
||||
"moderation": "Moderation",
|
||||
"live": "Live-Show"
|
||||
},
|
||||
"automation": {
|
||||
"title": "Automatik",
|
||||
"autoApproveHighlights": {
|
||||
"label": "Highlights automatisch freigeben",
|
||||
"help": "Wenn du ein ausstehendes Foto highlightest, wird es automatisch freigegeben."
|
||||
},
|
||||
"autoAddApproved": {
|
||||
"label": "Freigegebene automatisch in die Live-Show",
|
||||
"help": "Freigegebene oder gehighlightete Fotos gehen direkt in die Live-Show.",
|
||||
"locked": "Aktiv, weil Uploads sofort sichtbar sind."
|
||||
},
|
||||
"autoRemoveLive": {
|
||||
"label": "Aus der Live-Show entfernen",
|
||||
"help": "Versteckte oder abgelehnte Fotos werden automatisch aus der Live-Show entfernt."
|
||||
},
|
||||
"uploaders": {
|
||||
"title": "Uploader-Ausnahmen",
|
||||
"subtitle": "Wähle Geräte aus den letzten Uploads, die immer freigegeben oder immer geprüft werden sollen.",
|
||||
"trustedLabel": "Immer freigeben",
|
||||
"trustedHelp": "Uploads von diesen Geräten überspringen die Prüfung.",
|
||||
"forceLabel": "Immer prüfen",
|
||||
"forceHelp": "Uploads von diesen Geräten müssen immer geprüft werden.",
|
||||
"add": "Hinzufügen",
|
||||
"remove": "Entfernen",
|
||||
"empty": "Noch keine Ausnahmen."
|
||||
},
|
||||
"saveFailed": "Automatik-Einstellungen konnten nicht gespeichert werden."
|
||||
},
|
||||
"emptyModeration": "Keine Uploads passen zu diesem Filter.",
|
||||
"emptyLive": "Keine Fotos für die Live-Show in der Warteschlange."
|
||||
},
|
||||
|
||||
@@ -2484,6 +2484,34 @@
|
||||
"moderation": "Moderation",
|
||||
"live": "Live Show"
|
||||
},
|
||||
"automation": {
|
||||
"title": "Automation",
|
||||
"autoApproveHighlights": {
|
||||
"label": "Auto-approve highlights",
|
||||
"help": "When you highlight a pending photo it will be approved automatically."
|
||||
},
|
||||
"autoAddApproved": {
|
||||
"label": "Auto-add approved to Live Show",
|
||||
"help": "Approved or highlighted photos go straight to the Live Show.",
|
||||
"locked": "Enabled because uploads are visible immediately."
|
||||
},
|
||||
"autoRemoveLive": {
|
||||
"label": "Auto-remove from Live Show",
|
||||
"help": "Hidden or rejected photos are removed from the Live Show automatically."
|
||||
},
|
||||
"uploaders": {
|
||||
"title": "Uploader overrides",
|
||||
"subtitle": "Pick devices from recent uploads to always approve or always review their photos.",
|
||||
"trustedLabel": "Always approve",
|
||||
"trustedHelp": "Uploads from these devices skip the approval queue.",
|
||||
"forceLabel": "Always review",
|
||||
"forceHelp": "Uploads from these devices always need approval.",
|
||||
"add": "Add",
|
||||
"remove": "Remove",
|
||||
"empty": "No overrides yet."
|
||||
},
|
||||
"saveFailed": "Automation settings could not be saved."
|
||||
},
|
||||
"emptyModeration": "No uploads match this filter.",
|
||||
"emptyLive": "No photos waiting for Live Show."
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Check, Eye, EyeOff, Image as ImageIcon, RefreshCcw, Settings, Sparkles
|
||||
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';
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
approveAndLiveShowPhoto,
|
||||
approveLiveShowPhoto,
|
||||
clearLiveShowPhoto,
|
||||
ControlRoomSettings,
|
||||
ControlRoomUploaderRule,
|
||||
createEventAddonCheckout,
|
||||
EventAddonCatalogItem,
|
||||
EventLimitSummary,
|
||||
@@ -27,6 +30,7 @@ import {
|
||||
TenantPhoto,
|
||||
unfeaturePhoto,
|
||||
updatePhotoStatus,
|
||||
updateEvent,
|
||||
updatePhotoVisibility,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
@@ -252,17 +256,27 @@ function PhotoStatusTag({ label }: { label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
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 } = useEventContext();
|
||||
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 } = useAdminTheme();
|
||||
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[]>([]);
|
||||
@@ -287,6 +301,23 @@ export default function MobileEventControlRoomPage() {
|
||||
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);
|
||||
@@ -297,12 +328,152 @@ export default function MobileEventControlRoomPage() {
|
||||
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;
|
||||
@@ -506,6 +677,23 @@ export default function MobileEventControlRoomPage() {
|
||||
[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;
|
||||
@@ -566,7 +754,13 @@ export default function MobileEventControlRoomPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldAutoApproveHighlight = action === 'feature' && autoApproveHighlights && photo.status === 'pending';
|
||||
|
||||
if (!online) {
|
||||
if (shouldAutoApproveHighlight) {
|
||||
enqueueModerationActions(['approve', 'feature'], photo.id);
|
||||
return;
|
||||
}
|
||||
enqueueModerationAction(action, photo.id);
|
||||
return;
|
||||
}
|
||||
@@ -576,12 +770,21 @@ export default function MobileEventControlRoomPage() {
|
||||
try {
|
||||
let updated: TenantPhoto;
|
||||
if (action === 'approve') {
|
||||
updated = await updatePhotoStatus(slug, photo.id, 'approved');
|
||||
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);
|
||||
@@ -605,7 +808,16 @@ export default function MobileEventControlRoomPage() {
|
||||
setModerationBusyId(null);
|
||||
}
|
||||
},
|
||||
[enqueueModerationAction, online, slug, t, updatePhotoInCollections],
|
||||
[
|
||||
autoAddApprovedToLive,
|
||||
autoApproveHighlights,
|
||||
enqueueModerationAction,
|
||||
enqueueModerationActions,
|
||||
online,
|
||||
slug,
|
||||
t,
|
||||
updatePhotoInCollections,
|
||||
],
|
||||
);
|
||||
|
||||
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
|
||||
@@ -805,6 +1017,254 @@ export default function MobileEventControlRoomPage() {
|
||||
))}
|
||||
</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 ? (
|
||||
|
||||
@@ -4,6 +4,8 @@ import { render, screen } from '@testing-library/react';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
const selectEventMock = vi.fn();
|
||||
const refetchMock = vi.fn();
|
||||
const activeEventMock = { slug: 'demo-event', settings: { guest_upload_visibility: 'review', control_room: {} } };
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
@@ -36,8 +38,9 @@ vi.mock('../../auth/context', () => ({
|
||||
|
||||
vi.mock('../../context/EventContext', () => ({
|
||||
useEventContext: () => ({
|
||||
activeEvent: { slug: 'demo-event' },
|
||||
activeEvent: activeEventMock,
|
||||
selectEvent: selectEventMock,
|
||||
refetch: refetchMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -98,6 +101,13 @@ vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/switch', () => ({
|
||||
Switch: Object.assign(
|
||||
({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
{ Thumb: () => <div /> },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({
|
||||
children,
|
||||
@@ -143,6 +153,7 @@ vi.mock('../../api', () => ({
|
||||
approveAndLiveShowPhoto: vi.fn(),
|
||||
approveLiveShowPhoto: vi.fn(),
|
||||
clearLiveShowPhoto: vi.fn(),
|
||||
updateEvent: vi.fn(),
|
||||
createEventAddonCheckout: vi.fn(),
|
||||
featurePhoto: vi.fn(),
|
||||
getAddonCatalog: vi.fn().mockResolvedValue([]),
|
||||
|
||||
Reference in New Issue
Block a user