Files
fotospiel-app/resources/js/admin/mobile/EventLiveShowQueuePage.tsx
2026-01-05 14:04:05 +01:00

310 lines
11 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 {
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>
);
}