From dd459aa381f2033308f1675e2a6f01cc493f0359 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 20 Jan 2026 16:12:29 +0100 Subject: [PATCH] Replace control room filters with count bar --- .../js/admin/mobile/EventControlRoomPage.tsx | 221 ++++++++++++++++-- .../__tests__/EventControlRoomPage.test.tsx | 13 ++ 2 files changed, 209 insertions(+), 25 deletions(-) diff --git a/resources/js/admin/mobile/EventControlRoomPage.tsx b/resources/js/admin/mobile/EventControlRoomPage.tsx index 3d091f2..d37d68d 100644 --- a/resources/js/admin/mobile/EventControlRoomPage.tsx +++ b/resources/js/admin/mobile/EventControlRoomPage.tsx @@ -7,6 +7,8 @@ import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; import { Switch } from '@tamagui/switch'; import { Accordion } from '@tamagui/accordion'; +import { ScrollView } from '@tamagui/scroll-view'; +import { ToggleGroup } from '@tamagui/toggle-group'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; import { MobileField, MobileSelect } from './components/FormControls'; @@ -277,7 +279,7 @@ export default function MobileEventControlRoomPage() { 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 { textStrong, text, muted, border, accentSoft, accent, danger, primary, surfaceMuted, surface } = useAdminTheme(); const [activeTab, setActiveTab] = React.useState<'moderation' | 'live'>('moderation'); const [moderationPhotos, setModerationPhotos] = React.useState([]); @@ -287,6 +289,12 @@ export default function MobileEventControlRoomPage() { const [moderationLoading, setModerationLoading] = React.useState(true); const [moderationError, setModerationError] = React.useState(null); const [moderationBusyId, setModerationBusyId] = React.useState(null); + const [moderationCounts, setModerationCounts] = React.useState>({ + all: 0, + pending: 0, + featured: 0, + hidden: 0, + }); const [limits, setLimits] = React.useState(null); const [catalogAddons, setCatalogAddons] = React.useState([]); const [busyScope, setBusyScope] = React.useState(null); @@ -301,6 +309,14 @@ export default function MobileEventControlRoomPage() { const [liveLoading, setLiveLoading] = React.useState(true); const [liveError, setLiveError] = React.useState(null); const [liveBusyId, setLiveBusyId] = React.useState(null); + const [liveCounts, setLiveCounts] = React.useState>({ + pending: 0, + approved: 0, + rejected: 0, + none: 0, + all: 0, + expired: 0, + }); const [controlRoomSettings, setControlRoomSettings] = React.useState({ auto_approve_highlights: true, @@ -328,6 +344,8 @@ export default function MobileEventControlRoomPage() { const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); const infoBg = accentSoft; const infoBorder = accent; + const activeFilterBg = accentSoft; + const activeFilterBorder = accent; const saveControlRoomSettings = React.useCallback( async (nextSettings: ControlRoomSettings) => { @@ -558,6 +576,29 @@ export default function MobileEventControlRoomPage() { } }, [ensureSlug, moderationFilter, moderationPage, t]); + const loadModerationCounts = React.useCallback(async () => { + if (!slug) { + return; + } + + try { + const [all, pending, featured, hidden] = await Promise.all([ + getEventPhotos(slug, { page: 1, perPage: 1, sort: 'desc' }), + getEventPhotos(slug, { page: 1, perPage: 1, sort: 'desc', status: 'pending' }), + getEventPhotos(slug, { page: 1, perPage: 1, sort: 'desc', featured: true }), + getEventPhotos(slug, { page: 1, perPage: 1, sort: 'desc', status: 'hidden' }), + ]); + setModerationCounts({ + all: all.meta?.total ?? all.photos.length, + pending: pending.meta?.total ?? pending.photos.length, + featured: featured.meta?.total ?? featured.photos.length, + hidden: hidden.meta?.total ?? hidden.photos.length, + }); + } catch { + // ignore + } + }, [slug]); + const loadLiveQueue = React.useCallback(async () => { const resolvedSlug = await ensureSlug(); if (!resolvedSlug) { @@ -588,6 +629,35 @@ export default function MobileEventControlRoomPage() { } }, [ensureSlug, livePage, liveStatusFilter, t]); + const loadLiveCounts = React.useCallback(async () => { + if (!slug) { + return; + } + + try { + const statuses: LiveShowQueueStatus[] = ['pending', 'approved', 'rejected', 'none']; + const results = await Promise.all( + statuses.map((status) => getLiveShowQueue(slug, { page: 1, perPage: 1, liveStatus: status })) + ); + const nextCounts: Record = { + pending: results[0]?.meta?.total ?? results[0]?.photos.length ?? 0, + approved: results[1]?.meta?.total ?? results[1]?.photos.length ?? 0, + rejected: results[2]?.meta?.total ?? results[2]?.photos.length ?? 0, + none: results[3]?.meta?.total ?? results[3]?.photos.length ?? 0, + all: 0, + expired: 0, + }; + setLiveCounts(nextCounts); + } catch { + // ignore + } + }, [slug]); + + const refreshCounts = React.useCallback(() => { + void loadModerationCounts(); + void loadLiveCounts(); + }, [loadLiveCounts, loadModerationCounts]); + React.useEffect(() => { if (activeTab === 'moderation') { if (moderationResetRef.current && moderationPage !== 1) { @@ -608,6 +678,10 @@ export default function MobileEventControlRoomPage() { } }, [activeTab, loadLiveQueue, livePage]); + React.useEffect(() => { + refreshCounts(); + }, [refreshCounts]); + React.useEffect(() => { if (!location.search || !slug) { return; @@ -793,6 +867,7 @@ export default function MobileEventControlRoomPage() { } updatePhotoInCollections(updated); + refreshCounts(); triggerHaptic(action === 'approve' || action === 'feature' ? 'success' : 'medium'); } catch (err) { if (!isAuthError(err)) { @@ -886,6 +961,7 @@ export default function MobileEventControlRoomPage() { try { const updated = await approveLiveShowPhoto(slug, photo.id); setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); + refreshCounts(); toast.success(t('liveShowQueue.approveSuccess', 'Photo approved for Live Show')); } catch (err) { if (!isAuthError(err)) { @@ -902,6 +978,7 @@ export default function MobileEventControlRoomPage() { try { const updated = await approveAndLiveShowPhoto(slug, photo.id); setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); + refreshCounts(); toast.success(t('liveShowQueue.approveAndLiveSuccess', 'Photo approved and added to Live Show')); } catch (err) { if (!isAuthError(err)) { @@ -918,6 +995,7 @@ export default function MobileEventControlRoomPage() { try { const updated = await rejectLiveShowPhoto(slug, photo.id); setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); + refreshCounts(); toast.success(t('liveShowQueue.rejectSuccess', 'Photo removed from Live Show')); } catch (err) { if (!isAuthError(err)) { @@ -934,6 +1012,7 @@ export default function MobileEventControlRoomPage() { try { const updated = await clearLiveShowPhoto(slug, photo.id); setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); + refreshCounts(); toast.success(t('liveShowQueue.clearSuccess', 'Live Show approval removed')); } catch (err) { if (!isAuthError(err)) { @@ -1311,18 +1390,64 @@ export default function MobileEventControlRoomPage() { ) : null} - - setModerationFilter(event.target.value as ModerationFilter)} - > - {MODERATION_FILTERS.map((option) => ( - - ))} - - + + + {t('mobilePhotos.filtersTitle', 'Filter')} + + + + value && setModerationFilter(value as ModerationFilter)} + > + + {MODERATION_FILTERS.map((option) => { + const active = option.value === moderationFilter; + const count = moderationCounts[option.value] ?? 0; + return ( + + + + {t(option.labelKey, option.fallback)} + + + + {count} + + + + + ); + })} + + + + + {!moderationLoading ? ( @@ -1445,18 +1570,64 @@ export default function MobileEventControlRoomPage() { - - setLiveStatusFilter(event.target.value as LiveShowQueueStatus)} - > - {LIVE_STATUS_OPTIONS.map((option) => ( - - ))} - - + + + {t('liveShowQueue.filterLabel', 'Live status')} + + + + value && setLiveStatusFilter(value as LiveShowQueueStatus)} + > + + {LIVE_STATUS_OPTIONS.map((option) => { + const active = option.value === liveStatusFilter; + const count = liveCounts[option.value] ?? 0; + return ( + + + + {t(option.labelKey, option.fallback)} + + + + {count} + + + + + ); + })} + + + + + {liveError ? ( diff --git a/resources/js/admin/mobile/__tests__/EventControlRoomPage.test.tsx b/resources/js/admin/mobile/__tests__/EventControlRoomPage.test.tsx index 8de0f4d..5518347 100644 --- a/resources/js/admin/mobile/__tests__/EventControlRoomPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/EventControlRoomPage.test.tsx @@ -119,6 +119,19 @@ vi.mock('@tamagui/accordion', () => ({ ), })); +vi.mock('@tamagui/scroll-view', () => ({ + ScrollView: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@tamagui/toggle-group', () => ({ + ToggleGroup: Object.assign( + ({ children }: { children: React.ReactNode }) =>
{children}
, + { + Item: ({ children }: { children: React.ReactNode }) =>
{children}
, + }, + ), +})); + vi.mock('@tamagui/react-native-web-lite', () => ({ Pressable: ({ children,