Refine control room upload settings UI defaults
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-20 15:59:11 +01:00
parent 5e5b69f655
commit e490f9995c
6 changed files with 277 additions and 246 deletions

View File

@@ -2923,7 +2923,7 @@ class EventPublicController extends BaseController
$autoApproveUploads = $uploadVisibility === 'immediate'; $autoApproveUploads = $uploadVisibility === 'immediate';
$controlRoom = Arr::get($eventModel->settings ?? [], 'control_room', []); $controlRoom = Arr::get($eventModel->settings ?? [], 'control_room', []);
$controlRoom = is_array($controlRoom) ? $controlRoom : []; $controlRoom = is_array($controlRoom) ? $controlRoom : [];
$autoAddApprovedToLiveSetting = (bool) Arr::get($controlRoom, 'auto_add_approved_to_live', false); $autoAddApprovedToLiveSetting = (bool) Arr::get($controlRoom, 'auto_add_approved_to_live', true);
$trustedUploaders = Arr::get($controlRoom, 'trusted_uploaders', []); $trustedUploaders = Arr::get($controlRoom, 'trusted_uploaders', []);
$forceReviewUploaders = Arr::get($controlRoom, 'force_review_uploaders', []); $forceReviewUploaders = Arr::get($controlRoom, 'force_review_uploaders', []);
$autoAddApprovedToLiveDefault = $autoAddApprovedToLiveSetting || $autoApproveUploads; $autoAddApprovedToLiveDefault = $autoAddApprovedToLiveSetting || $autoApproveUploads;

View File

@@ -133,7 +133,7 @@ class PhotoController extends Controller
$photo->status = $validated['visible'] ? 'approved' : 'hidden'; $photo->status = $validated['visible'] ? 'approved' : 'hidden';
$photo->save(); $photo->save();
$autoRemoveLiveOnHide = (bool) Arr::get($event->settings ?? [], 'control_room.auto_remove_live_on_hide', false); $autoRemoveLiveOnHide = (bool) Arr::get($event->settings ?? [], 'control_room.auto_remove_live_on_hide', true);
if ($autoRemoveLiveOnHide && ! $validated['visible']) { if ($autoRemoveLiveOnHide && ! $validated['visible']) {
$photo->rejectForLiveShow($request->user(), 'hidden'); $photo->rejectForLiveShow($request->user(), 'hidden');
} }
@@ -537,7 +537,7 @@ class PhotoController extends Controller
$photo->update($validated); $photo->update($validated);
$autoRemoveLiveOnHide = (bool) Arr::get($event->settings ?? [], 'control_room.auto_remove_live_on_hide', false); $autoRemoveLiveOnHide = (bool) Arr::get($event->settings ?? [], 'control_room.auto_remove_live_on_hide', true);
if ($autoRemoveLiveOnHide && ($validated['status'] ?? null) === 'rejected') { if ($autoRemoveLiveOnHide && ($validated['status'] ?? null) === 'rejected') {
$photo->rejectForLiveShow($request->user()); $photo->rejectForLiveShow($request->user());
} }

View File

@@ -2484,6 +2484,7 @@
}, },
"automation": { "automation": {
"title": "Automatik", "title": "Automatik",
"sectionTitle": "Einstellungen für Uploads",
"autoApproveHighlights": { "autoApproveHighlights": {
"label": "Highlights automatisch freigeben", "label": "Highlights automatisch freigeben",
"help": "Wenn du ein ausstehendes Foto highlightest, wird es automatisch freigegeben." "help": "Wenn du ein ausstehendes Foto highlightest, wird es automatisch freigegeben."

View File

@@ -2486,6 +2486,7 @@
}, },
"automation": { "automation": {
"title": "Automation", "title": "Automation",
"sectionTitle": "Einstellungen für Uploads",
"autoApproveHighlights": { "autoApproveHighlights": {
"label": "Auto-approve highlights", "label": "Auto-approve highlights",
"help": "When you highlight a pending photo it will be approved automatically." "help": "When you highlight a pending photo it will be approved automatically."

View File

@@ -1,11 +1,12 @@
import React from 'react'; import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Check, Eye, EyeOff, Image as ImageIcon, RefreshCcw, Settings, Sparkles } from 'lucide-react'; import { Check, ChevronDown, Eye, EyeOff, Image as ImageIcon, RefreshCcw, Settings, Sparkles } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { Switch } from '@tamagui/switch'; import { Switch } from '@tamagui/switch';
import { Accordion } from '@tamagui/accordion';
import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
import { MobileField, MobileSelect } from './components/FormControls'; import { MobileField, MobileSelect } from './components/FormControls';
@@ -302,9 +303,9 @@ export default function MobileEventControlRoomPage() {
const [liveBusyId, setLiveBusyId] = React.useState<number | null>(null); const [liveBusyId, setLiveBusyId] = React.useState<number | null>(null);
const [controlRoomSettings, setControlRoomSettings] = React.useState<ControlRoomSettings>({ const [controlRoomSettings, setControlRoomSettings] = React.useState<ControlRoomSettings>({
auto_approve_highlights: false, auto_approve_highlights: true,
auto_add_approved_to_live: false, auto_add_approved_to_live: true,
auto_remove_live_on_hide: false, auto_remove_live_on_hide: true,
trusted_uploaders: [], trusted_uploaders: [],
force_review_uploaders: [], force_review_uploaders: [],
}); });
@@ -463,10 +464,11 @@ export default function MobileEventControlRoomPage() {
React.useEffect(() => { React.useEffect(() => {
const settings = (activeEvent?.settings?.control_room ?? {}) as ControlRoomSettings; const settings = (activeEvent?.settings?.control_room ?? {}) as ControlRoomSettings;
const resolveFlag = (value: boolean | undefined) => (typeof value === 'boolean' ? value : true);
setControlRoomSettings({ setControlRoomSettings({
auto_approve_highlights: Boolean(settings.auto_approve_highlights), auto_approve_highlights: resolveFlag(settings.auto_approve_highlights),
auto_add_approved_to_live: Boolean(settings.auto_add_approved_to_live), auto_add_approved_to_live: resolveFlag(settings.auto_add_approved_to_live),
auto_remove_live_on_hide: Boolean(settings.auto_remove_live_on_hide), auto_remove_live_on_hide: resolveFlag(settings.auto_remove_live_on_hide),
trusted_uploaders: Array.isArray(settings.trusted_uploaders) ? settings.trusted_uploaders : [], trusted_uploaders: Array.isArray(settings.trusted_uploaders) ? settings.trusted_uploaders : [],
force_review_uploaders: Array.isArray(settings.force_review_uploaders) ? settings.force_review_uploaders : [], force_review_uploaders: Array.isArray(settings.force_review_uploaders) ? settings.force_review_uploaders : [],
}); });
@@ -1018,251 +1020,267 @@ export default function MobileEventControlRoomPage() {
</XStack> </XStack>
<MobileCard> <MobileCard>
<YStack space="$2"> <Accordion type="single" collapsible defaultValue="upload-settings">
<Text fontSize="$sm" fontWeight="800" color={text}> <Accordion.Item value="upload-settings">
{t('controlRoom.automation.title', 'Automation')} <Accordion.Trigger
</Text> {...({
<MobileField padding: '$2',
label={t('controlRoom.automation.autoApproveHighlights.label', 'Auto-approve highlights')} borderWidth: 1,
hint={t( borderColor: border,
'controlRoom.automation.autoApproveHighlights.help', borderRadius: 14,
'When you highlight a pending photo it will be approved automatically.', backgroundColor: surfaceMuted,
)} } as any)}
>
<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 /> <XStack justifyContent="space-between" alignItems="center" flex={1}>
</Switch> <Text fontSize="$sm" fontWeight="800" color={text}>
</MobileField> {t('controlRoom.automation.sectionTitle', 'Einstellungen für Uploads')}
<MobileField </Text>
label={t('controlRoom.automation.autoAddApproved.label', 'Auto-add approved to Live Show')} <ChevronDown size={16} color={muted} />
hint={ </XStack>
isImmediateUploads </Accordion.Trigger>
? t( <Accordion.Content {...({ paddingTop: '$2' } as any)}>
'controlRoom.automation.autoAddApproved.locked', <YStack space="$3">
'Enabled because uploads are visible immediately.', <YStack space="$2">
) <Text fontSize="$sm" fontWeight="800" color={text}>
: t( {t('controlRoom.automation.title', 'Automation')}
'controlRoom.automation.autoAddApproved.help', </Text>
'Approved or highlighted photos go straight to the Live Show.', <MobileField
) label={t('controlRoom.automation.autoApproveHighlights.label', 'Auto-approve highlights')}
} hint={t(
> 'controlRoom.automation.autoApproveHighlights.help',
<Switch 'When you highlight a pending photo it will be approved automatically.',
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"> <Switch
<Text fontSize="$sm" fontWeight="700" color={text}> size="$3"
{rule.label ?? t('common.anonymous', 'Anonymous')} checked={autoApproveHighlights}
</Text>
<Text fontSize="$xs" color={muted}>
{formatDeviceId(rule.device_id)}
</Text>
</YStack>
<Pressable
onPress={() => removeUploaderRule('trusted', rule.device_id)}
disabled={controlRoomSaving} disabled={controlRoomSaving}
aria-label={t('controlRoom.automation.uploaders.remove', 'Remove')} onCheckedChange={(checked) =>
style={{ saveControlRoomSettings({
paddingHorizontal: 12, ...controlRoomSettings,
paddingVertical: 6, auto_approve_highlights: Boolean(checked),
borderRadius: 999, })
backgroundColor: withAlpha(danger, 0.12), }
}} aria-label={t('controlRoom.automation.autoApproveHighlights.label', 'Auto-approve highlights')}
> >
<Text fontSize="$xs" fontWeight="700" color={danger}> <Switch.Thumb />
{t('controlRoom.automation.uploaders.remove', 'Remove')} </Switch>
</Text> </MobileField>
</Pressable> <MobileField
</XStack> label={t('controlRoom.automation.autoAddApproved.label', 'Auto-add approved to Live Show')}
))} hint={
</YStack> isImmediateUploads
) : ( ? t(
<Text fontSize="$xs" color={muted}> 'controlRoom.automation.autoAddApproved.locked',
{t('controlRoom.automation.uploaders.empty', 'No overrides yet.')} 'Enabled because uploads are visible immediately.',
</Text> )
)} : t(
</MobileField> 'controlRoom.automation.autoAddApproved.help',
'Approved or highlighted photos go straight to the Live Show.',
<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"> <Switch
<Text fontSize="$sm" fontWeight="700" color={text}> size="$3"
{rule.label ?? t('common.anonymous', 'Anonymous')} checked={autoAddApprovedToLive}
</Text> disabled={controlRoomSaving || isImmediateUploads}
<Text fontSize="$xs" color={muted}> onCheckedChange={(checked) => {
{formatDeviceId(rule.device_id)} if (isImmediateUploads) {
</Text> return;
</YStack> }
<Pressable saveControlRoomSettings({
onPress={() => removeUploaderRule('force', rule.device_id)} ...controlRoomSettings,
disabled={controlRoomSaving} auto_add_approved_to_live: Boolean(checked),
aria-label={t('controlRoom.automation.uploaders.remove', 'Remove')} });
style={{
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: withAlpha(danger, 0.12),
}} }}
aria-label={t('controlRoom.automation.autoAddApproved.label', 'Auto-add approved to Live Show')}
> >
<Text fontSize="$xs" fontWeight="700" color={danger}> <Switch.Thumb />
{t('controlRoom.automation.uploaders.remove', 'Remove')} </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> </Text>
</Pressable> )}
</XStack> </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> </YStack>
) : ( </Accordion.Content>
<Text fontSize="$xs" color={muted}> </Accordion.Item>
{t('controlRoom.automation.uploaders.empty', 'No overrides yet.')} </Accordion>
</Text>
)}
</MobileField>
</YStack>
</MobileCard> </MobileCard>
{activeTab === 'moderation' ? ( {activeTab === 'moderation' ? (

View File

@@ -108,6 +108,17 @@ vi.mock('@tamagui/switch', () => ({
), ),
})); }));
vi.mock('@tamagui/accordion', () => ({
Accordion: Object.assign(
({ children }: { children: React.ReactNode }) => <div>{children}</div>,
{
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Trigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Content: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
},
),
}));
vi.mock('@tamagui/react-native-web-lite', () => ({ vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ Pressable: ({
children, children,