Add live show moderation queue
This commit is contained in:
@@ -132,6 +132,11 @@ export type TenantPhoto = {
|
||||
url: string | null;
|
||||
thumbnail_url: string | null;
|
||||
status: string;
|
||||
live_status?: string | null;
|
||||
live_approved_at?: string | null;
|
||||
live_reviewed_at?: string | null;
|
||||
live_rejection_reason?: string | null;
|
||||
live_priority?: number | null;
|
||||
is_featured: boolean;
|
||||
likes_count: number;
|
||||
uploaded_at: string;
|
||||
@@ -911,6 +916,11 @@ function normalizePhoto(photo: TenantPhoto): TenantPhoto {
|
||||
url: photo.url,
|
||||
thumbnail_url: photo.thumbnail_url ?? photo.url,
|
||||
status: photo.status ?? 'approved',
|
||||
live_status: photo.live_status ?? null,
|
||||
live_approved_at: photo.live_approved_at ?? null,
|
||||
live_reviewed_at: photo.live_reviewed_at ?? null,
|
||||
live_rejection_reason: photo.live_rejection_reason ?? null,
|
||||
live_priority: typeof photo.live_priority === 'number' ? photo.live_priority : null,
|
||||
is_featured: Boolean(photo.is_featured),
|
||||
likes_count: Number(photo.likes_count ?? 0),
|
||||
uploaded_at: photo.uploaded_at,
|
||||
@@ -1489,6 +1499,14 @@ export type GetEventPhotosOptions = {
|
||||
visibility?: 'visible' | 'hidden' | 'all';
|
||||
};
|
||||
|
||||
export type LiveShowQueueStatus = 'pending' | 'approved' | 'rejected' | 'none' | 'expired' | 'all';
|
||||
|
||||
export type GetLiveShowQueueOptions = {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
liveStatus?: LiveShowQueueStatus;
|
||||
};
|
||||
|
||||
export async function getEventPhotos(
|
||||
slug: string,
|
||||
options: GetEventPhotosOptions = {}
|
||||
@@ -1526,6 +1544,67 @@ export async function getEventPhotos(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLiveShowQueue(
|
||||
slug: string,
|
||||
options: GetLiveShowQueueOptions = {}
|
||||
): Promise<{ photos: TenantPhoto[]; meta: PaginationMeta }> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.page) params.set('page', String(options.page));
|
||||
if (options.perPage) params.set('per_page', String(options.perPage));
|
||||
if (options.liveStatus) params.set('live_status', options.liveStatus);
|
||||
|
||||
const response = await authorizedFetch(
|
||||
`${eventEndpoint(slug)}/live-show/photos${params.toString() ? `?${params.toString()}` : ''}`
|
||||
);
|
||||
const data = await jsonOrThrow<{
|
||||
data?: TenantPhoto[];
|
||||
meta?: Partial<PaginationMeta>;
|
||||
current_page?: number;
|
||||
last_page?: number;
|
||||
per_page?: number;
|
||||
total?: number;
|
||||
}>(response, 'Failed to load live show queue');
|
||||
|
||||
const meta = buildPagination(data as unknown as JsonValue, options.perPage ?? 20);
|
||||
|
||||
return {
|
||||
photos: (data.data ?? []).map(normalizePhoto),
|
||||
meta,
|
||||
};
|
||||
}
|
||||
|
||||
export async function approveLiveShowPhoto(
|
||||
slug: string,
|
||||
id: number,
|
||||
payload: { priority?: number } = {}
|
||||
): Promise<TenantPhoto> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/photos/${id}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to approve live show photo');
|
||||
return normalizePhoto(data.data);
|
||||
}
|
||||
|
||||
export async function rejectLiveShowPhoto(slug: string, id: number, reason?: string): Promise<TenantPhoto> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/photos/${id}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to reject live show photo');
|
||||
return normalizePhoto(data.data);
|
||||
}
|
||||
|
||||
export async function clearLiveShowPhoto(slug: string, id: number): Promise<TenantPhoto> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/photos/${id}/clear`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to clear live show photo');
|
||||
return normalizePhoto(data.data);
|
||||
}
|
||||
|
||||
export async function getEventPhoto(slug: string, id: number): Promise<TenantPhoto> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}`);
|
||||
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to load photo');
|
||||
|
||||
@@ -33,3 +33,4 @@ export const ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH = (slug: string): string =>
|
||||
adminPath(`/mobile/events/${encodeURIComponent(slug)}/guest-notifications`);
|
||||
export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}`);
|
||||
export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/branding`);
|
||||
export const ADMIN_EVENT_LIVE_SHOW_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/live-show`);
|
||||
|
||||
@@ -310,6 +310,7 @@
|
||||
"tasks": "Aufgaben & Checklisten",
|
||||
"qr": "QR-Code-Layouts",
|
||||
"images": "Bildverwaltung",
|
||||
"liveShow": "Live-Show-Warteschlange",
|
||||
"guests": "Gästeverwaltung",
|
||||
"guestMessages": "Gästebenachrichtigungen",
|
||||
"branding": "Branding & Design",
|
||||
@@ -2142,6 +2143,33 @@
|
||||
"queueWaiting": "Offline",
|
||||
"syncFailed": "Synchronisierung fehlgeschlagen. Bitte später erneut versuchen."
|
||||
},
|
||||
"liveShowQueue": {
|
||||
"title": "Live-Show-Warteschlange",
|
||||
"subtitle": "Fotos für die Live-Slideshow freigeben",
|
||||
"filterLabel": "Live-Status",
|
||||
"statusPending": "Ausstehend",
|
||||
"statusApproved": "Freigegeben",
|
||||
"statusRejected": "Abgelehnt",
|
||||
"statusNone": "Nicht vorgemerkt",
|
||||
"status": {
|
||||
"pending": "Ausstehend",
|
||||
"approved": "Freigegeben",
|
||||
"rejected": "Abgelehnt",
|
||||
"none": "Nicht vorgemerkt"
|
||||
},
|
||||
"galleryApproved": "Galerie freigegeben",
|
||||
"galleryApprovedOnly": "Hier erscheinen nur bereits freigegebene Galerie-Fotos.",
|
||||
"offlineNotice": "Du bist offline. Live-Show-Aktionen sind deaktiviert.",
|
||||
"empty": "Keine Fotos für die Live-Show in der Warteschlange.",
|
||||
"loadFailed": "Live-Show-Warteschlange konnte nicht geladen werden.",
|
||||
"approve": "Für Live-Show freigeben",
|
||||
"reject": "Ablehnen",
|
||||
"clear": "Aus Live-Show entfernen",
|
||||
"approveSuccess": "Foto für Live-Show freigegeben",
|
||||
"rejectSuccess": "Foto aus Live-Show entfernt",
|
||||
"clearSuccess": "Live-Show-Freigabe entfernt",
|
||||
"actionFailed": "Live-Show-Aktion fehlgeschlagen."
|
||||
},
|
||||
"mobileProfile": {
|
||||
"title": "Profil",
|
||||
"settings": "Einstellungen",
|
||||
|
||||
@@ -305,6 +305,7 @@
|
||||
"tasks": "Tasks & checklists",
|
||||
"qr": "QR code layouts",
|
||||
"images": "Image management",
|
||||
"liveShow": "Live Show queue",
|
||||
"guests": "Guest management",
|
||||
"guestMessages": "Guest messages",
|
||||
"branding": "Branding & theme",
|
||||
@@ -2146,6 +2147,33 @@
|
||||
"queueWaiting": "Offline",
|
||||
"syncFailed": "Sync failed. Please try again later."
|
||||
},
|
||||
"liveShowQueue": {
|
||||
"title": "Live Show queue",
|
||||
"subtitle": "Approve photos for the live slideshow",
|
||||
"filterLabel": "Live status",
|
||||
"statusPending": "Pending",
|
||||
"statusApproved": "Approved",
|
||||
"statusRejected": "Rejected",
|
||||
"statusNone": "Not queued",
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"none": "Not queued"
|
||||
},
|
||||
"galleryApproved": "Gallery approved",
|
||||
"galleryApprovedOnly": "Only gallery-approved photos appear here.",
|
||||
"offlineNotice": "You are offline. Live Show actions are disabled.",
|
||||
"empty": "No photos waiting for Live Show.",
|
||||
"loadFailed": "Live Show queue could not be loaded.",
|
||||
"approve": "Approve for Live Show",
|
||||
"reject": "Reject",
|
||||
"clear": "Remove from Live Show",
|
||||
"approveSuccess": "Photo approved for Live Show",
|
||||
"rejectSuccess": "Photo removed from Live Show",
|
||||
"clearSuccess": "Live Show approval removed",
|
||||
"actionFailed": "Live Show update failed."
|
||||
},
|
||||
"mobileProfile": {
|
||||
"title": "Profile",
|
||||
"settings": "Settings",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, Pencil, Megaphone } from 'lucide-react';
|
||||
import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, Pencil, Megaphone, Tv } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, PillBadge, KpiTile, ActionTile } from './components/Primitives';
|
||||
import { TenantEvent, EventStats, EventToolkit, getEvent, getEventStats, getEventToolkit, getEvents } from '../api';
|
||||
import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants';
|
||||
import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_LIVE_SHOW_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
@@ -250,12 +250,20 @@ export default function MobileEventDetailPage() {
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 2}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Tv}
|
||||
label={t('events.quick.liveShow', 'Live Show queue')}
|
||||
color={ADMIN_ACTION_COLORS.images}
|
||||
onPress={() => slug && navigate(ADMIN_EVENT_LIVE_SHOW_PATH(slug))}
|
||||
disabled={!slug}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 3}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Users}
|
||||
label={t('events.quick.guests', 'Guest Management')}
|
||||
color={ADMIN_ACTION_COLORS.guests}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/members`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 3}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 4}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Megaphone}
|
||||
@@ -263,7 +271,7 @@ export default function MobileEventDetailPage() {
|
||||
color={ADMIN_ACTION_COLORS.guestMessages}
|
||||
onPress={() => slug && navigate(ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH(slug))}
|
||||
disabled={!slug}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 4}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 5}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Layout}
|
||||
@@ -273,14 +281,14 @@ export default function MobileEventDetailPage() {
|
||||
brandingAllowed ? () => navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`)) : undefined
|
||||
}
|
||||
disabled={!brandingAllowed}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 5}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 6}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Camera}
|
||||
label={t('events.quick.photobooth', 'Photobooth')}
|
||||
color={ADMIN_ACTION_COLORS.photobooth}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photobooth`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 6}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 7}
|
||||
/>
|
||||
{isPastEvent(event?.event_date) ? (
|
||||
<ActionTile
|
||||
@@ -288,7 +296,7 @@ export default function MobileEventDetailPage() {
|
||||
label={t('events.quick.recap', 'Recap & Archive')}
|
||||
color={ADMIN_ACTION_COLORS.recap}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/recap`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 7}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 8}
|
||||
/>
|
||||
) : null}
|
||||
</XStack>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ const MobileQrPrintPage = React.lazy(() => import('./mobile/QrPrintPage'));
|
||||
const MobileQrLayoutCustomizePage = React.lazy(() => import('./mobile/QrLayoutCustomizePage'));
|
||||
const MobileEventGuestNotificationsPage = React.lazy(() => import('./mobile/EventGuestNotificationsPage'));
|
||||
const MobileEventPhotosPage = React.lazy(() => import('./mobile/EventPhotosPage'));
|
||||
const MobileEventLiveShowQueuePage = React.lazy(() => import('./mobile/EventLiveShowQueuePage'));
|
||||
const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage'));
|
||||
const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage'));
|
||||
const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage'));
|
||||
@@ -194,6 +195,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'mobile/events/:slug/qr', element: <RequireAdminAccess><MobileQrPrintPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/qr/customize/:tokenId?', element: <RequireAdminAccess><MobileQrLayoutCustomizePage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/photos/:photoId?', element: <MobileEventPhotosPage /> },
|
||||
{ path: 'mobile/events/:slug/live-show', element: <RequireAdminAccess><MobileEventLiveShowQueuePage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/recap', element: <RequireAdminAccess><MobileEventRecapPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },
|
||||
|
||||
Reference in New Issue
Block a user