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 { 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<TenantPhoto[]>([]);
@@ -287,6 +289,12 @@ export default function MobileEventControlRoomPage() {
const [moderationLoading, setModerationLoading] = React.useState(true);
const [moderationError, setModerationError] = React.useState<string | 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 [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [busyScope, setBusyScope] = React.useState<string | null>(null);
@@ -301,6 +309,14 @@ export default function MobileEventControlRoomPage() {
const [liveLoading, setLiveLoading] = React.useState(true);
const [liveError, setLiveError] = React.useState<string | 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>({
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<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(() => {
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}
<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>
<YStack space="$2">
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('mobilePhotos.filtersTitle', 'Filter')}
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<XStack
alignItems="center"
padding="$1"
borderRadius={999}
borderWidth={1}
borderColor={border}
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>
{!moderationLoading ? (
@@ -1445,18 +1570,64 @@ export default function MobileEventControlRoomPage() {
</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>
<YStack space="$2">
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('liveShowQueue.filterLabel', 'Live status')}
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<XStack
alignItems="center"
padding="$1"
borderRadius={999}
borderWidth={1}
borderColor={border}
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>
{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', () => ({
Pressable: ({
children,