Add live show moderation queue
This commit is contained in:
309
resources/js/admin/mobile/EventLiveShowQueuePage.tsx
Normal file
309
resources/js/admin/mobile/EventLiveShowQueuePage.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import { MobileSelect } from './components/FormControls';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import {
|
||||
approveLiveShowPhoto,
|
||||
clearLiveShowPhoto,
|
||||
getEvents,
|
||||
getLiveShowQueue,
|
||||
LiveShowQueueStatus,
|
||||
rejectLiveShowPhoto,
|
||||
TenantEvent,
|
||||
TenantPhoto,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { adminPath } from '../constants';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
||||
|
||||
const STATUS_OPTIONS: Array<{ value: LiveShowQueueStatus; labelKey: string; fallback: string }> = [
|
||||
{ value: 'pending', labelKey: 'liveShowQueue.statusPending', fallback: 'Pending' },
|
||||
{ value: 'approved', labelKey: 'liveShowQueue.statusApproved', fallback: 'Approved' },
|
||||
{ value: 'rejected', labelKey: 'liveShowQueue.statusRejected', fallback: 'Rejected' },
|
||||
{ value: 'none', labelKey: 'liveShowQueue.statusNone', fallback: 'Not queued' },
|
||||
];
|
||||
|
||||
export default function MobileEventLiveShowQueuePage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { activeEvent, selectEvent } = useEventContext();
|
||||
const slug = slugParam ?? activeEvent?.slug ?? null;
|
||||
const online = useOnlineStatus();
|
||||
const { textStrong, text, muted, border, danger } = useAdminTheme();
|
||||
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
|
||||
const [statusFilter, setStatusFilter] = React.useState<LiveShowQueueStatus>('pending');
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [hasMore, setHasMore] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [busyId, setBusyId] = React.useState<number | null>(null);
|
||||
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
|
||||
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (slugParam && activeEvent?.slug !== slugParam) {
|
||||
selectEvent(slugParam);
|
||||
}
|
||||
}, [slugParam, activeEvent?.slug, selectEvent]);
|
||||
|
||||
const loadQueue = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
if (!fallbackAttempted) {
|
||||
setFallbackAttempted(true);
|
||||
try {
|
||||
const events = await getEvents({ force: true });
|
||||
const first = events[0] as TenantEvent | undefined;
|
||||
if (first?.slug) {
|
||||
selectEvent(first.slug);
|
||||
navigate(adminPath(`/mobile/events/${first.slug}/live-show`), { replace: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
setError(t('events.errors.missingSlug', 'No event selected.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getLiveShowQueue(slug, {
|
||||
page,
|
||||
perPage: 20,
|
||||
liveStatus: statusFilter,
|
||||
});
|
||||
setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos]));
|
||||
const lastPage = result.meta?.last_page ?? 1;
|
||||
setHasMore(page < lastPage);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
const message = getApiErrorMessage(err, t('liveShowQueue.loadFailed', 'Live Show queue could not be loaded.'));
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug, page, statusFilter, fallbackAttempted, navigate, selectEvent, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [statusFilter]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadQueue();
|
||||
}, [loadQueue]);
|
||||
|
||||
async function handleApprove(photo: TenantPhoto) {
|
||||
if (!slug || busyId) return;
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = await approveLiveShowPhoto(slug, photo.id);
|
||||
setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
|
||||
toast.success(t('liveShowQueue.approveSuccess', 'Photo approved for Live Show'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(photo: TenantPhoto) {
|
||||
if (!slug || busyId) return;
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = await rejectLiveShowPhoto(slug, photo.id);
|
||||
setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
|
||||
toast.success(t('liveShowQueue.rejectSuccess', 'Photo removed from Live Show'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(photo: TenantPhoto) {
|
||||
if (!slug || busyId) return;
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = await clearLiveShowPhoto(slug, photo.id);
|
||||
setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
|
||||
toast.success(t('liveShowQueue.clearSuccess', 'Live Show approval removed'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusTone(status?: string | null): 'success' | 'warning' | 'muted' {
|
||||
if (status === 'approved') return 'success';
|
||||
if (status === 'pending') return 'warning';
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
title={t('liveShowQueue.title', 'Live Show queue')}
|
||||
subtitle={t('liveShowQueue.subtitle', 'Approve photos for the live slideshow')}
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<HeaderActionButton onPress={() => loadQueue()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
<RefreshCcw size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
}
|
||||
>
|
||||
<MobileCard borderColor={border} backgroundColor="transparent">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('liveShowQueue.galleryApprovedOnly', 'Only gallery-approved photos appear here.')}
|
||||
</Text>
|
||||
{!online ? (
|
||||
<Text fontSize="$sm" color={danger}>
|
||||
{t('liveShowQueue.offlineNotice', 'You are offline. Live Show actions are disabled.')}
|
||||
</Text>
|
||||
) : null}
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard>
|
||||
<MobileSelect
|
||||
label={t('liveShowQueue.filterLabel', 'Live status')}
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as LiveShowQueueStatus)}
|
||||
>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</option>
|
||||
))}
|
||||
</MobileSelect>
|
||||
</MobileCard>
|
||||
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
{loading && page === 1 ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<SkeletonCard key={`skeleton-${idx}`} height={120} />
|
||||
))}
|
||||
</YStack>
|
||||
) : photos.length === 0 ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color={text}>
|
||||
{t('liveShowQueue.empty', 'No photos waiting for Live Show.')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
{photos.map((photo) => {
|
||||
const isBusy = busyId === photo.id;
|
||||
const liveStatus = photo.live_status ?? 'pending';
|
||||
return (
|
||||
<MobileCard key={photo.id}>
|
||||
<XStack space="$3" alignItems="center">
|
||||
{photo.thumbnail_url ? (
|
||||
<img
|
||||
src={photo.thumbnail_url}
|
||||
alt={photo.original_name ?? 'Photo'}
|
||||
style={{
|
||||
width: 86,
|
||||
height: 86,
|
||||
borderRadius: 14,
|
||||
objectFit: 'cover',
|
||||
border: `1px solid ${border}`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<YStack flex={1} space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<PillBadge tone="success">
|
||||
{t('liveShowQueue.galleryApproved', 'Gallery approved')}
|
||||
</PillBadge>
|
||||
<PillBadge tone={resolveStatusTone(liveStatus)}>
|
||||
{t(`liveShowQueue.status.${liveStatus}`, liveStatus)}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{photo.uploaded_at}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<XStack space="$2" marginTop="$2">
|
||||
{liveStatus !== 'approved' ? (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.approve', 'Approve for Live Show')}
|
||||
onPress={() => handleApprove(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="primary"
|
||||
/>
|
||||
) : (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.clear', 'Remove from Live Show')}
|
||||
onPress={() => handleClear(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="ghost"
|
||||
/>
|
||||
)}
|
||||
{liveStatus !== 'rejected' ? (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.reject', 'Reject')}
|
||||
onPress={() => handleReject(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="danger"
|
||||
/>
|
||||
) : (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.clear', 'Remove from Live Show')}
|
||||
onPress={() => handleClear(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="ghost"
|
||||
/>
|
||||
)}
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
{hasMore ? (
|
||||
<MobileCard>
|
||||
<CTAButton
|
||||
label={t('common.loadMore', 'Load more')}
|
||||
onPress={() => setPage((prev) => prev + 1)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user