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 { 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 ? (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user