360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
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 {
|
|
approveAndLiveShowPhoto,
|
|
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 handleApproveAndLive(photo: TenantPhoto) {
|
|
if (!slug || busyId) return;
|
|
setBusyId(photo.id);
|
|
try {
|
|
const updated = await approveAndLiveShowPhoto(slug, photo.id);
|
|
setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
|
|
toast.success(t('liveShowQueue.approveAndLiveSuccess', 'Photo approved and added to 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';
|
|
}
|
|
|
|
function resolveGalleryLabel(status?: string | null): string {
|
|
const fallbackMap: Record<string, string> = {
|
|
approved: 'Gallery approved',
|
|
pending: 'Gallery pending',
|
|
rejected: 'Gallery rejected',
|
|
hidden: 'Hidden',
|
|
};
|
|
const key = status ?? 'pending';
|
|
return t(`liveShowQueue.galleryStatus.${key}`, fallbackMap[key] ?? key);
|
|
}
|
|
|
|
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',
|
|
'Gallery and Live Show approvals are separate. Pending photos can be approved 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';
|
|
const galleryStatus = photo.status ?? 'pending';
|
|
const canApproveGallery = galleryStatus === 'pending';
|
|
const canApproveLiveOnly = galleryStatus === 'approved';
|
|
const canApproveLive = canApproveGallery || canApproveLiveOnly;
|
|
const showApproveAction = liveStatus !== 'approved';
|
|
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={resolveStatusTone(galleryStatus)}>
|
|
{resolveGalleryLabel(galleryStatus)}
|
|
</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">
|
|
{showApproveAction ? (
|
|
<CTAButton
|
|
label={
|
|
canApproveGallery
|
|
? t('liveShowQueue.approveAndLive', 'Approve + Live')
|
|
: canApproveLiveOnly
|
|
? t('liveShowQueue.approve', 'Approve for Live Show')
|
|
: t('liveShowQueue.notEligible', 'Not eligible')
|
|
}
|
|
onPress={() => {
|
|
if (canApproveGallery) {
|
|
void handleApproveAndLive(photo);
|
|
return;
|
|
}
|
|
if (canApproveLiveOnly) {
|
|
void handleApprove(photo);
|
|
}
|
|
}}
|
|
disabled={!online || !canApproveLive}
|
|
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>
|
|
);
|
|
}
|