Replace control room filters with count bar
This commit is contained in:
@@ -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)}
|
||||
<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}
|
||||
>
|
||||
{MODERATION_FILTERS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
<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)}
|
||||
</option>
|
||||
))}
|
||||
</MobileSelect>
|
||||
</MobileField>
|
||||
</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)}
|
||||
<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}
|
||||
>
|
||||
{LIVE_STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
<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)}
|
||||
</option>
|
||||
))}
|
||||
</MobileSelect>
|
||||
</MobileField>
|
||||
</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 ? (
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user