From 5674ed99f1e07c77a45713fc6c5a794064c30cf6 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 20 Jan 2026 13:53:53 +0100 Subject: [PATCH] Add compact control room photo grid --- .../js/admin/mobile/EventControlRoomPage.tsx | 495 ++++++++++++------ .../__tests__/EventControlRoomPage.test.tsx | 179 +++++++ 2 files changed, 503 insertions(+), 171 deletions(-) create mode 100644 resources/js/admin/mobile/__tests__/EventControlRoomPage.test.tsx diff --git a/resources/js/admin/mobile/EventControlRoomPage.tsx b/resources/js/admin/mobile/EventControlRoomPage.tsx index 087c588..a24396b 100644 --- a/resources/js/admin/mobile/EventControlRoomPage.tsx +++ b/resources/js/admin/mobile/EventControlRoomPage.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Image as ImageIcon, RefreshCcw, Settings } from 'lucide-react'; +import { Check, Eye, EyeOff, Image as ImageIcon, RefreshCcw, Settings, Sparkles } 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, CTAButton, PillBadge, SkeletonCard } from './components/Primitives'; +import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; import { MobileField, MobileSelect } from './components/FormControls'; import { useEventContext } from '../context/EventContext'; import { @@ -37,6 +37,7 @@ import { useBackNavigation } from './hooks/useBackNavigation'; import { useAdminTheme } from './theme'; import { useOnlineStatus } from './hooks/useOnlineStatus'; import { useAuth } from '../auth/context'; +import { withAlpha } from './components/colors'; import { enqueuePhotoAction, loadPhotoQueue, @@ -45,7 +46,7 @@ import { type PhotoModerationAction, } from './lib/photoModerationQueue'; import { triggerHaptic } from './lib/haptics'; -import { normalizeLiveStatus, resolveLiveShowApproveMode, resolveStatusTone } from './lib/controlRoom'; +import { normalizeLiveStatus, resolveLiveShowApproveMode } from './lib/controlRoom'; import { LegalConsentSheet } from './components/LegalConsentSheet'; import { selectAddonKeyForScope } from './addons'; import { LimitWarnings } from './components/LimitWarnings'; @@ -86,6 +87,167 @@ function translateLimits(t: (key: string, defaultValue?: string, options?: Recor return (key, options) => t(`limits.${key}`, defaults[key] ?? key, options); } +type PhotoGridAction = { + key: string; + label: string; + icon: React.ComponentType<{ size?: number; color?: string }>; + onPress: () => void; + disabled?: boolean; +}; + +function PhotoGrid({ + photos, + actionsForPhoto, + badgesForPhoto, + isBusy, +}: { + photos: TenantPhoto[]; + actionsForPhoto: (photo: TenantPhoto) => PhotoGridAction[]; + badgesForPhoto?: (photo: TenantPhoto) => string[]; + isBusy?: (photo: TenantPhoto) => boolean; +}) { + const gridStyle: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', + gap: 10, + }; + + return ( +
+ {photos.map((photo) => ( + + ))} +
+ ); +} + +function PhotoGridTile({ + photo, + actions, + badges, + isBusy, +}: { + photo: TenantPhoto; + actions: PhotoGridAction[]; + badges: string[]; + isBusy: boolean; +}) { + const { border, muted, surfaceMuted } = useAdminTheme(); + const overlayBg = withAlpha('#0f172a', 0.65); + const actionBg = withAlpha('#ffffff', 0.14); + + return ( +
+ {photo.thumbnail_url ? ( + {photo.original_name + ) : ( + + + + )} + + {badges.length ? ( + + {badges.map((label) => ( + + ))} + + ) : null} + + + {actions.map((action) => ( + + ))} + +
+ ); +} + +function PhotoActionButton({ + label, + icon: Icon, + onPress, + disabled = false, + backgroundColor, +}: { + label: string; + icon: React.ComponentType<{ size?: number; color?: string }>; + onPress: () => void; + disabled?: boolean; + backgroundColor: string; +}) { + return ( + + + + + {label} + + + + ); +} + +function PhotoStatusTag({ label }: { label: string }) { + return ( + + + {label} + + + ); +} + export default function MobileEventControlRoomPage() { const { slug: slugParam } = useParams<{ slug?: string }>(); const navigate = useNavigate(); @@ -288,28 +450,42 @@ export default function MobileEventControlRoomPage() { setQueuedActions(queue); }, []); + const updatePhotoInCollections = React.useCallback((updated: TenantPhoto) => { + setModerationPhotos((prev) => prev.map((photo) => (photo.id === updated.id ? updated : photo))); + setLivePhotos((prev) => prev.map((photo) => (photo.id === updated.id ? updated : photo))); + }, []); + const applyOptimisticUpdate = React.useCallback((photoId: number, action: PhotoModerationAction['action']) => { - setModerationPhotos((prev) => - prev.map((photo) => { - if (photo.id !== photoId) { - return photo; - } - - if (action === 'approve') { - return { ...photo, status: 'approved' }; - } - - if (action === 'hide') { - return { ...photo, status: 'hidden' }; - } - - if (action === 'show') { - return { ...photo, status: 'approved' }; - } - + const applyUpdate = (photo: TenantPhoto) => { + if (photo.id !== photoId) { return photo; - }), - ); + } + + if (action === 'approve') { + return { ...photo, status: 'approved' }; + } + + if (action === 'hide') { + return { ...photo, status: 'hidden' }; + } + + if (action === 'show') { + return { ...photo, status: 'approved' }; + } + + if (action === 'feature') { + return { ...photo, is_featured: true }; + } + + if (action === 'unfeature') { + return { ...photo, is_featured: false }; + } + + return photo; + }; + + setModerationPhotos((prev) => prev.map(applyUpdate)); + setLivePhotos((prev) => prev.map(applyUpdate)); }, []); const enqueueModerationAction = React.useCallback( @@ -359,7 +535,7 @@ export default function MobileEventControlRoomPage() { remaining = removePhotoAction(remaining, entry.id); if (updated && entry.eventSlug === slug) { - setModerationPhotos((prev) => prev.map((photo) => (photo.id === updated!.id ? updated! : photo))); + updatePhotoInCollections(updated); } } catch (err) { toast.error(t('mobilePhotos.syncFailed', 'Sync failed. Please try again later.')); @@ -372,7 +548,7 @@ export default function MobileEventControlRoomPage() { updateQueueState(remaining); setSyncingQueue(false); syncingQueueRef.current = false; - }, [online, slug, t, updateQueueState]); + }, [online, slug, t, updatePhotoInCollections, updateQueueState]); React.useEffect(() => { if (online) { @@ -399,15 +575,25 @@ export default function MobileEventControlRoomPage() { updated = await updatePhotoStatus(slug, photo.id, 'approved'); } else if (action === 'hide') { updated = await updatePhotoVisibility(slug, photo.id, true); - } else { + } else if (action === 'show') { updated = await updatePhotoVisibility(slug, photo.id, false); + } else if (action === 'feature') { + updated = await featurePhoto(slug, photo.id); + } else { + updated = await unfeaturePhoto(slug, photo.id); } - setModerationPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); - triggerHaptic(action === 'approve' ? 'success' : 'medium'); + updatePhotoInCollections(updated); + triggerHaptic(action === 'approve' || action === 'feature' ? 'success' : 'medium'); } catch (err) { if (!isAuthError(err)) { - const message = getApiErrorMessage(err, t('mobilePhotos.visibilityFailed', 'Visibility could not be changed.')); + const fallbackMessage = + action === 'approve' + ? t('mobilePhotos.approveFailed', 'Approval failed.') + : action === 'feature' || action === 'unfeature' + ? t('mobilePhotos.featureFailed', 'Highlight could not be changed.') + : t('mobilePhotos.visibilityFailed', 'Visibility could not be changed.'); + const message = getApiErrorMessage(err, fallbackMessage); setModerationError(message); toast.error(message); } @@ -415,7 +601,7 @@ export default function MobileEventControlRoomPage() { setModerationBusyId(null); } }, - [enqueueModerationAction, online, slug, t], + [enqueueModerationAction, online, slug, t, updatePhotoInCollections], ); function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) { @@ -691,67 +877,57 @@ export default function MobileEventControlRoomPage() { ) : ( - - {moderationPhotos.map((photo) => { + moderationBusyId === photo.id} + badgesForPhoto={(photo) => { + const badges: string[] = []; + if (photo.status === 'hidden') { + badges.push(t('photos.filters.hidden', 'Hidden')); + } + if (photo.is_featured) { + badges.push(t('photos.filters.featured', 'Highlights')); + } + return badges; + }} + actionsForPhoto={(photo) => { const isBusy = moderationBusyId === photo.id; const galleryStatus = photo.status ?? 'pending'; - const liveStatus = normalizeLiveStatus(photo.live_status); const canApprove = galleryStatus === 'pending'; const canShow = galleryStatus === 'hidden'; const visibilityAction: PhotoModerationAction['action'] = canShow ? 'show' : 'hide'; const visibilityLabel = canShow ? t('photos.actions.show', 'Show') : t('photos.actions.hide', 'Hide'); - return ( - - - {photo.thumbnail_url ? ( - {photo.original_name - ) : null} - - - {photo.original_name ?? t('common.photo', 'Photo')} - - - - {resolveGalleryLabel(galleryStatus)} - - - {resolveLiveLabel(liveStatus)} - - - - - - handleModerationAction('approve', photo)} - disabled={!canApprove} - loading={isBusy} - tone="primary" - /> - handleModerationAction(visibilityAction, photo)} - disabled={false} - loading={isBusy} - tone="ghost" - /> - - - ); - })} - + const featureAction: PhotoModerationAction['action'] = photo.is_featured ? 'unfeature' : 'feature'; + const featureLabel = photo.is_featured + ? t('photos.actions.unfeature', 'Remove highlight') + : t('photos.actions.feature', 'Set highlight'); + return [ + { + key: 'approve', + label: t('photos.actions.approve', 'Approve'), + icon: Check, + onPress: () => handleModerationAction('approve', photo), + disabled: !canApprove || isBusy, + }, + { + key: 'visibility', + label: visibilityLabel, + icon: canShow ? Eye : EyeOff, + onPress: () => handleModerationAction(visibilityAction, photo), + disabled: isBusy, + }, + { + key: 'feature', + label: featureLabel, + icon: Sparkles, + onPress: () => handleModerationAction(featureAction, photo), + disabled: isBusy, + }, + ]; + }} + /> )} {moderationHasMore ? ( @@ -816,99 +992,76 @@ export default function MobileEventControlRoomPage() { ) : ( - - {livePhotos.map((photo) => { - const isBusy = liveBusyId === photo.id; + moderationBusyId === photo.id || liveBusyId === photo.id} + badgesForPhoto={(photo) => { + const badges: string[] = []; + const liveStatus = normalizeLiveStatus(photo.live_status); + if (liveStatus !== 'pending') { + badges.push(resolveLiveLabel(liveStatus)); + } + if (photo.status === 'hidden') { + badges.push(t('photos.filters.hidden', 'Hidden')); + } + if (photo.is_featured) { + badges.push(t('photos.filters.featured', 'Highlights')); + } + return badges; + }} + actionsForPhoto={(photo) => { + const isLiveBusy = liveBusyId === photo.id; + const isModerationBusy = moderationBusyId === photo.id; const liveStatus = normalizeLiveStatus(photo.live_status); const galleryStatus = photo.status ?? 'pending'; const approveMode = resolveLiveShowApproveMode(galleryStatus); const canApproveLive = approveMode !== 'not-eligible'; - const showApproveAction = liveStatus !== 'approved'; - const approveLabel = - approveMode === 'approve-and-live' - ? t('liveShowQueue.approveAndLive', 'Approve + Live') - : approveMode === 'approve-only' - ? t('liveShowQueue.approve', 'Approve for Live Show') - : t('liveShowQueue.notEligible', 'Not eligible'); + const approveDisabled = !online || !canApproveLive || liveStatus === 'approved' || isLiveBusy; + const visibilityLabel = t('photos.actions.hide', 'Hide'); + const featureAction: PhotoModerationAction['action'] = photo.is_featured ? 'unfeature' : 'feature'; + const featureLabel = photo.is_featured + ? t('photos.actions.unfeature', 'Remove highlight') + : t('photos.actions.feature', 'Set highlight'); - return ( - - - {photo.thumbnail_url ? ( - {photo.original_name - ) : null} - - - {photo.original_name ?? t('common.photo', 'Photo')} - - - - {resolveGalleryLabel(galleryStatus)} - - - {resolveLiveLabel(liveStatus)} - - - - - - {showApproveAction ? ( - { - if (approveMode === 'approve-and-live') { - void handleApproveAndLive(photo); - return; - } - if (approveMode === 'approve-only') { - void handleApprove(photo); - } - }} - disabled={!online || !canApproveLive} - loading={isBusy} - tone="primary" - /> - ) : ( - handleClear(photo)} - disabled={!online} - loading={isBusy} - tone="ghost" - /> - )} - {liveStatus !== 'rejected' ? ( - handleReject(photo)} - disabled={!online} - loading={isBusy} - tone="danger" - /> - ) : ( - handleClear(photo)} - disabled={!online} - loading={isBusy} - tone="ghost" - /> - )} - - - ); - })} - + return [ + { + key: 'approve', + label: t('photos.actions.approve', 'Approve'), + icon: Check, + onPress: () => { + if (approveMode === 'approve-and-live') { + void handleApproveAndLive(photo); + return; + } + if (approveMode === 'approve-only') { + void handleApprove(photo); + } + }, + disabled: approveDisabled, + }, + { + key: 'visibility', + label: visibilityLabel, + icon: EyeOff, + onPress: () => { + if (liveStatus === 'approved' || liveStatus === 'rejected') { + void handleClear(photo); + return; + } + void handleReject(photo); + }, + disabled: !online || isLiveBusy || isModerationBusy, + }, + { + key: 'feature', + label: featureLabel, + icon: Sparkles, + onPress: () => handleModerationAction(featureAction, photo), + disabled: isModerationBusy, + }, + ]; + }} + /> )} {liveHasMore ? ( diff --git a/resources/js/admin/mobile/__tests__/EventControlRoomPage.test.tsx b/resources/js/admin/mobile/__tests__/EventControlRoomPage.test.tsx new file mode 100644 index 0000000..d6b5e5b --- /dev/null +++ b/resources/js/admin/mobile/__tests__/EventControlRoomPage.test.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +const navigateMock = vi.fn(); +const selectEventMock = vi.fn(); + +vi.mock('react-router-dom', () => ({ + useNavigate: () => navigateMock, + useLocation: () => ({ search: '', pathname: '/event-admin/mobile/events/demo-event/control-room' }), + useParams: () => ({ slug: 'demo-event' }), +})); + +const tMock = (key: string, fallback?: string | Record, options?: Record) => { + if (typeof fallback === 'string') { + return fallback; + } + if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') { + return fallback.defaultValue; + } + if (options?.defaultValue) { + return String(options.defaultValue); + } + return key; +}; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: tMock, + }), +})); + +vi.mock('../../auth/context', () => ({ + useAuth: () => ({ user: { role: 'tenant_admin' } }), +})); + +vi.mock('../../context/EventContext', () => ({ + useEventContext: () => ({ + activeEvent: { slug: 'demo-event' }, + selectEvent: selectEventMock, + }), +})); + +vi.mock('../hooks/useOnlineStatus', () => ({ + useOnlineStatus: () => true, +})); + +vi.mock('../hooks/useBackNavigation', () => ({ + useBackNavigation: () => undefined, +})); + +vi.mock('../components/MobileShell', () => ({ + MobileShell: ({ children }: { children: React.ReactNode }) =>
{children}
, + HeaderActionButton: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('../components/Primitives', () => ({ + MobileCard: ({ children }: { children: React.ReactNode }) =>
{children}
, + CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => ( + + ), + SkeletonCard: () =>
Loading...
, +})); + +vi.mock('../components/FormControls', () => ({ + MobileField: ({ label, children }: { label: string; children: React.ReactNode }) => ( + + ), + MobileSelect: (props: React.SelectHTMLAttributes) =>