Replace control room filters with count bar
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 16:12:29 +01:00
parent 02ec14a0d3
commit dd459aa381
2 changed files with 209 additions and 25 deletions

View File

@@ -7,6 +7,8 @@ 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 { Accordion } from '@tamagui/accordion';
import { ScrollView } from '@tamagui/scroll-view';
import { ToggleGroup } from '@tamagui/toggle-group';
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';
@@ -277,7 +279,7 @@ export default function MobileEventControlRoomPage() {
const isMember = user?.role === 'member'; const isMember = user?.role === 'member';
const slug = slugParam ?? activeEvent?.slug ?? null; const slug = slugParam ?? activeEvent?.slug ?? null;
const online = useOnlineStatus(); 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 [activeTab, setActiveTab] = React.useState<'moderation' | 'live'>('moderation');
const [moderationPhotos, setModerationPhotos] = React.useState<TenantPhoto[]>([]); const [moderationPhotos, setModerationPhotos] = React.useState<TenantPhoto[]>([]);
@@ -287,6 +289,12 @@ export default function MobileEventControlRoomPage() {
const [moderationLoading, setModerationLoading] = React.useState(true); const [moderationLoading, setModerationLoading] = React.useState(true);
const [moderationError, setModerationError] = React.useState<string | null>(null); const [moderationError, setModerationError] = React.useState<string | null>(null);
const [moderationBusyId, setModerationBusyId] = React.useState<number | null>(null); const [moderationBusyId, setModerationBusyId] = React.useState<number | null>(null);
const [moderationCounts, setModerationCounts] = React.useState<Record<ModerationFilter, number>>({
all: 0,
pending: 0,
featured: 0,
hidden: 0,
});
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null); const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]); const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [busyScope, setBusyScope] = React.useState<string | null>(null); const [busyScope, setBusyScope] = React.useState<string | null>(null);
@@ -301,6 +309,14 @@ export default function MobileEventControlRoomPage() {
const [liveLoading, setLiveLoading] = React.useState(true); const [liveLoading, setLiveLoading] = React.useState(true);
const [liveError, setLiveError] = React.useState<string | null>(null); const [liveError, setLiveError] = React.useState<string | null>(null);
const [liveBusyId, setLiveBusyId] = React.useState<number | null>(null); const [liveBusyId, setLiveBusyId] = React.useState<number | null>(null);
const [liveCounts, setLiveCounts] = React.useState<Record<LiveShowQueueStatus, number>>({
pending: 0,
approved: 0,
rejected: 0,
none: 0,
all: 0,
expired: 0,
});
const [controlRoomSettings, setControlRoomSettings] = React.useState<ControlRoomSettings>({ const [controlRoomSettings, setControlRoomSettings] = React.useState<ControlRoomSettings>({
auto_approve_highlights: true, auto_approve_highlights: true,
@@ -328,6 +344,8 @@ export default function MobileEventControlRoomPage() {
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const infoBg = accentSoft; const infoBg = accentSoft;
const infoBorder = accent; const infoBorder = accent;
const activeFilterBg = accentSoft;
const activeFilterBorder = accent;
const saveControlRoomSettings = React.useCallback( const saveControlRoomSettings = React.useCallback(
async (nextSettings: ControlRoomSettings) => { async (nextSettings: ControlRoomSettings) => {
@@ -558,6 +576,29 @@ export default function MobileEventControlRoomPage() {
} }
}, [ensureSlug, moderationFilter, moderationPage, t]); }, [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 loadLiveQueue = React.useCallback(async () => {
const resolvedSlug = await ensureSlug(); const resolvedSlug = await ensureSlug();
if (!resolvedSlug) { if (!resolvedSlug) {
@@ -588,6 +629,35 @@ export default function MobileEventControlRoomPage() {
} }
}, [ensureSlug, livePage, liveStatusFilter, t]); }, [ensureSlug, livePage, liveStatusFilter, t]);
const loadLiveCounts = React.useCallback(async () => {
if (!slug) {
return;
}
try {
const statuses: LiveShowQueueStatus[] = ['pending', 'approved', 'rejected', 'none'];
const results = await Promise.all(
statuses.map((status) => getLiveShowQueue(slug, { page: 1, perPage: 1, liveStatus: status }))
);
const nextCounts: Record<LiveShowQueueStatus, number> = {
pending: results[0]?.meta?.total ?? results[0]?.photos.length ?? 0,
approved: results[1]?.meta?.total ?? results[1]?.photos.length ?? 0,
rejected: results[2]?.meta?.total ?? results[2]?.photos.length ?? 0,
none: results[3]?.meta?.total ?? results[3]?.photos.length ?? 0,
all: 0,
expired: 0,
};
setLiveCounts(nextCounts);
} catch {
// ignore
}
}, [slug]);
const refreshCounts = React.useCallback(() => {
void loadModerationCounts();
void loadLiveCounts();
}, [loadLiveCounts, loadModerationCounts]);
React.useEffect(() => { React.useEffect(() => {
if (activeTab === 'moderation') { if (activeTab === 'moderation') {
if (moderationResetRef.current && moderationPage !== 1) { if (moderationResetRef.current && moderationPage !== 1) {
@@ -608,6 +678,10 @@ export default function MobileEventControlRoomPage() {
} }
}, [activeTab, loadLiveQueue, livePage]); }, [activeTab, loadLiveQueue, livePage]);
React.useEffect(() => {
refreshCounts();
}, [refreshCounts]);
React.useEffect(() => { React.useEffect(() => {
if (!location.search || !slug) { if (!location.search || !slug) {
return; return;
@@ -793,6 +867,7 @@ export default function MobileEventControlRoomPage() {
} }
updatePhotoInCollections(updated); updatePhotoInCollections(updated);
refreshCounts();
triggerHaptic(action === 'approve' || action === 'feature' ? 'success' : 'medium'); triggerHaptic(action === 'approve' || action === 'feature' ? 'success' : 'medium');
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -886,6 +961,7 @@ export default function MobileEventControlRoomPage() {
try { try {
const updated = await approveLiveShowPhoto(slug, photo.id); const updated = await approveLiveShowPhoto(slug, photo.id);
setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
refreshCounts();
toast.success(t('liveShowQueue.approveSuccess', 'Photo approved for Live Show')); toast.success(t('liveShowQueue.approveSuccess', 'Photo approved for Live Show'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -902,6 +978,7 @@ export default function MobileEventControlRoomPage() {
try { try {
const updated = await approveAndLiveShowPhoto(slug, photo.id); const updated = await approveAndLiveShowPhoto(slug, photo.id);
setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
refreshCounts();
toast.success(t('liveShowQueue.approveAndLiveSuccess', 'Photo approved and added to Live Show')); toast.success(t('liveShowQueue.approveAndLiveSuccess', 'Photo approved and added to Live Show'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -918,6 +995,7 @@ export default function MobileEventControlRoomPage() {
try { try {
const updated = await rejectLiveShowPhoto(slug, photo.id); const updated = await rejectLiveShowPhoto(slug, photo.id);
setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
refreshCounts();
toast.success(t('liveShowQueue.rejectSuccess', 'Photo removed from Live Show')); toast.success(t('liveShowQueue.rejectSuccess', 'Photo removed from Live Show'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -934,6 +1012,7 @@ export default function MobileEventControlRoomPage() {
try { try {
const updated = await clearLiveShowPhoto(slug, photo.id); const updated = await clearLiveShowPhoto(slug, photo.id);
setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
refreshCounts();
toast.success(t('liveShowQueue.clearSuccess', 'Live Show approval removed')); toast.success(t('liveShowQueue.clearSuccess', 'Live Show approval removed'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -1311,18 +1390,64 @@ export default function MobileEventControlRoomPage() {
) : null} ) : null}
<MobileCard> <MobileCard>
<MobileField label={t('mobilePhotos.filtersTitle', 'Filter')}> <YStack space="$2">
<MobileSelect <Text fontSize="$xs" fontWeight="800" color={text}>
value={moderationFilter} {t('mobilePhotos.filtersTitle', 'Filter')}
onChange={(event) => setModerationFilter(event.target.value as ModerationFilter)} </Text>
> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
{MODERATION_FILTERS.map((option) => ( <XStack
<option key={option.value} value={option.value}> alignItems="center"
{t(option.labelKey, option.fallback)} padding="$1"
</option> borderRadius={999}
))} borderWidth={1}
</MobileSelect> borderColor={border}
</MobileField> backgroundColor={surfaceMuted}
>
<ToggleGroup
type="single"
value={moderationFilter}
onValueChange={(value) => value && setModerationFilter(value as ModerationFilter)}
>
<XStack space="$1.5">
{MODERATION_FILTERS.map((option) => {
const active = option.value === moderationFilter;
const count = moderationCounts[option.value] ?? 0;
return (
<ToggleGroup.Item
key={option.value}
value={option.value}
borderRadius={999}
borderWidth={1}
borderColor={active ? activeFilterBorder : 'transparent'}
backgroundColor={active ? activeFilterBg : 'transparent'}
paddingVertical="$1.5"
paddingHorizontal="$3"
>
<XStack alignItems="center" space="$1.5">
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
{t(option.labelKey, option.fallback)}
</Text>
<XStack
paddingHorizontal="$1.5"
paddingVertical="$0.5"
borderRadius={999}
borderWidth={1}
borderColor={active ? activeFilterBorder : border}
backgroundColor={surface}
>
<Text fontSize={10} fontWeight="800" color={active ? primary : muted}>
{count}
</Text>
</XStack>
</XStack>
</ToggleGroup.Item>
);
})}
</XStack>
</ToggleGroup>
</XStack>
</ScrollView>
</YStack>
</MobileCard> </MobileCard>
{!moderationLoading ? ( {!moderationLoading ? (
@@ -1445,18 +1570,64 @@ export default function MobileEventControlRoomPage() {
</MobileCard> </MobileCard>
<MobileCard> <MobileCard>
<MobileField label={t('liveShowQueue.filterLabel', 'Live status')}> <YStack space="$2">
<MobileSelect <Text fontSize="$xs" fontWeight="800" color={text}>
value={liveStatusFilter} {t('liveShowQueue.filterLabel', 'Live status')}
onChange={(event) => setLiveStatusFilter(event.target.value as LiveShowQueueStatus)} </Text>
> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
{LIVE_STATUS_OPTIONS.map((option) => ( <XStack
<option key={option.value} value={option.value}> alignItems="center"
{t(option.labelKey, option.fallback)} padding="$1"
</option> borderRadius={999}
))} borderWidth={1}
</MobileSelect> borderColor={border}
</MobileField> backgroundColor={surfaceMuted}
>
<ToggleGroup
type="single"
value={liveStatusFilter}
onValueChange={(value) => value && setLiveStatusFilter(value as LiveShowQueueStatus)}
>
<XStack space="$1.5">
{LIVE_STATUS_OPTIONS.map((option) => {
const active = option.value === liveStatusFilter;
const count = liveCounts[option.value] ?? 0;
return (
<ToggleGroup.Item
key={option.value}
value={option.value}
borderRadius={999}
borderWidth={1}
borderColor={active ? activeFilterBorder : 'transparent'}
backgroundColor={active ? activeFilterBg : 'transparent'}
paddingVertical="$1.5"
paddingHorizontal="$3"
>
<XStack alignItems="center" space="$1.5">
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
{t(option.labelKey, option.fallback)}
</Text>
<XStack
paddingHorizontal="$1.5"
paddingVertical="$0.5"
borderRadius={999}
borderWidth={1}
borderColor={active ? activeFilterBorder : border}
backgroundColor={surface}
>
<Text fontSize={10} fontWeight="800" color={active ? primary : muted}>
{count}
</Text>
</XStack>
</XStack>
</ToggleGroup.Item>
);
})}
</XStack>
</ToggleGroup>
</XStack>
</ScrollView>
</YStack>
</MobileCard> </MobileCard>
{liveError ? ( {liveError ? (

View File

@@ -119,6 +119,19 @@ vi.mock('@tamagui/accordion', () => ({
), ),
})); }));
vi.mock('@tamagui/scroll-view', () => ({
ScrollView: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/toggle-group', () => ({
ToggleGroup: Object.assign(
({ children }: { children: React.ReactNode }) => <div>{children}</div>,
{
Item: ({ 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,