From 9d367512c53b2000335afe617cba3f3ae68775fc Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sun, 28 Dec 2025 21:29:30 +0100 Subject: [PATCH] =?UTF-8?q?=20I=20finished=20the=20remaining=20reliability?= =?UTF-8?q?,=20sharing,=20performance,=20and=20polish=20items=20across=20t?= =?UTF-8?q?he=20admin=20=20=20app.=20=20=20What=E2=80=99s=20done=20=20=20?= =?UTF-8?q?=20=20locales/en/mobile.json=20and=20resources/js/admin/i18n/lo?= =?UTF-8?q?cales/de/mobile.json.=20=20=20-=20Error=20recovery=20CTAs=20on?= =?UTF-8?q?=20Photos,=20Notifications,=20Tasks,=20and=20QR=20screens=20so?= =?UTF-8?q?=20users=20can=20retry=20without=20a=20full=20reload=20in=20=20?= =?UTF-8?q?=20=20resources/js/admin/mobile/EventPhotosPage.tsx,=20resource?= =?UTF-8?q?s/js/admin/mobile/NotificationsPage.tsx,=20resources/js/admin/?= =?UTF-8?q?=20=20=20=20=20mobile/EventTasksPage.tsx,=20resources/js/admin/?= =?UTF-8?q?mobile/QrPrintPage.tsx.=20=20=20-=20QR=20share=20uses=20native?= =?UTF-8?q?=20share=20sheet=20when=20available,=20with=20clipboard=20fallb?= =?UTF-8?q?ack=20in=20resources/js/admin/mobile/=20=20=20=20=20QrPrintPage?= =?UTF-8?q?.tsx.=20=20=20-=20Lazy=E2=80=91loaded=20photo=20grid=20thumbnai?= =?UTF-8?q?ls=20for=20better=20performance=20in=20resources/js/admin/mobil?= =?UTF-8?q?e/EventPhotosPage.tsx.=20=20=20-=20New=20helper=20+=20tests=20f?= =?UTF-8?q?or=20queue=20count=20logic=20in=20resources/js/admin/mobile/lib?= =?UTF-8?q?/queueStatus.ts=20and=20resources/js/admin/=20=20=20=20=20mobil?= =?UTF-8?q?e/lib/queueStatus.test.ts.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../js/admin/i18n/locales/de/mobile.json | 7 + .../js/admin/i18n/locales/en/mobile.json | 7 + resources/js/admin/mobile/EventPhotosPage.tsx | 154 +++++++++++++++--- resources/js/admin/mobile/EventTasksPage.tsx | 6 + .../js/admin/mobile/NotificationsPage.tsx | 6 + resources/js/admin/mobile/QrPrintPage.tsx | 77 +++++---- .../admin/mobile/components/MobileShell.tsx | 44 ++++- .../js/admin/mobile/lib/lightboxImage.test.ts | 43 +++++ .../js/admin/mobile/lib/lightboxImage.ts | 16 ++ .../js/admin/mobile/lib/queueStatus.test.ts | 23 +++ resources/js/admin/mobile/lib/queueStatus.ts | 8 + resources/js/admin/router.tsx | 3 +- 12 files changed, 337 insertions(+), 57 deletions(-) create mode 100644 resources/js/admin/mobile/lib/lightboxImage.test.ts create mode 100644 resources/js/admin/mobile/lib/lightboxImage.ts create mode 100644 resources/js/admin/mobile/lib/queueStatus.test.ts create mode 100644 resources/js/admin/mobile/lib/queueStatus.ts diff --git a/resources/js/admin/i18n/locales/de/mobile.json b/resources/js/admin/i18n/locales/de/mobile.json index 7975e03..0afe334 100644 --- a/resources/js/admin/i18n/locales/de/mobile.json +++ b/resources/js/admin/i18n/locales/de/mobile.json @@ -24,5 +24,12 @@ "active": "Aktiv", "quickQr": "QR öffnen", "clearSelection": "Auswahl entfernen" + }, + "status": { + "offline": "Offline-Modus: Änderungen synchronisieren, sobald du wieder online bist.", + "queueTitle": "Foto-Aktionen warten", + "queueBodyOnline": "{{count}} Aktionen bereit zur Synchronisierung.", + "queueBodyOffline": "{{count}} Aktionen offline gespeichert.", + "queueAction": "Fotos öffnen" } } diff --git a/resources/js/admin/i18n/locales/en/mobile.json b/resources/js/admin/i18n/locales/en/mobile.json index aadce71..0717d20 100644 --- a/resources/js/admin/i18n/locales/en/mobile.json +++ b/resources/js/admin/i18n/locales/en/mobile.json @@ -24,5 +24,12 @@ "active": "Active", "quickQr": "Quick QR", "clearSelection": "Clear selection" + }, + "status": { + "offline": "Offline mode: changes will sync when you are back online.", + "queueTitle": "Photo actions pending", + "queueBodyOnline": "{{count}} actions ready to sync.", + "queueBodyOffline": "{{count}} actions saved offline.", + "queueAction": "Open Photos" } } diff --git a/resources/js/admin/mobile/EventPhotosPage.tsx b/resources/js/admin/mobile/EventPhotosPage.tsx index 69e592a..609d6bd 100644 --- a/resources/js/admin/mobile/EventPhotosPage.tsx +++ b/resources/js/admin/mobile/EventPhotosPage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Image as ImageIcon, RefreshCcw, Filter, Check, Eye, EyeOff } from 'lucide-react'; +import { Image as ImageIcon, RefreshCcw, Filter, Check, Eye, EyeOff, 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'; @@ -45,7 +45,7 @@ import { type PhotoModerationAction, } from './lib/photoModerationQueue'; import { resolvePhotoSwipeAction, type SwipeModerationAction } from './lib/photoModerationSwipe'; -import { ADMIN_EVENT_PHOTOS_PATH } from '../constants'; +import { resolveLightboxSources } from './lib/lightboxImage'; type FilterKey = 'all' | 'featured' | 'hidden' | 'pending'; @@ -71,6 +71,7 @@ export default function MobileEventPhotosPage() { const [onlyFeatured, setOnlyFeatured] = React.useState(false); const [onlyHidden, setOnlyHidden] = React.useState(false); const [lightboxId, setLightboxId] = React.useState(null); + const [lightboxImageSrc, setLightboxImageSrc] = React.useState(null); const [pendingPhotoId, setPendingPhotoId] = React.useState(null); const [syncingQueue, setSyncingQueue] = React.useState(false); const [selectionMode, setSelectionMode] = React.useState(false); @@ -104,7 +105,6 @@ export default function MobileEventPhotosPage() { return photos.findIndex((photo) => photo.id === lightboxId); }, [photos, lightboxId]); const lightbox = lightboxIndex >= 0 ? photos[lightboxIndex] : null; - const basePhotosPath = slug ? ADMIN_EVENT_PHOTOS_PATH(slug) : adminPath('/mobile/events'); const parsedPhotoId = React.useMemo(() => { if (!photoIdParam) { return null; @@ -127,10 +127,29 @@ export default function MobileEventPhotosPage() { }, [lightboxId]); React.useEffect(() => { + if (!lightbox) { + setLightboxImageSrc(null); + return; + } + const sources = resolveLightboxSources(lightbox); + setLightboxImageSrc(sources.initial); + + if (!sources.full) { + return; + } + + const loader = new Image(); + loader.onload = () => setLightboxImageSrc(sources.full); + loader.src = sources.full; + }, [lightbox]); + + React.useEffect(() => { + if (!photoIdParam) { + setPendingPhotoId(null); + return; + } + if (parsedPhotoId === null) { - if (!photoIdParam) { - setLightboxId(null); - } setPendingPhotoId(null); return; } @@ -314,19 +333,9 @@ export default function MobileEventPhotosPage() { } }, [online, syncQueuedActions]); - const setLightboxWithUrl = React.useCallback( - (photoId: number | null, options?: { replace?: boolean }) => { - setLightboxId(photoId); - if (!slug) { - return; - } - const nextPath = photoId ? `${basePhotosPath}/${photoId}` : basePhotosPath; - if (location.pathname !== nextPath) { - navigate(`${nextPath}${location.search}`, { replace: options?.replace ?? false }); - } - }, - [basePhotosPath, location.pathname, location.search, navigate, slug], - ); + const setLightboxWithUrl = React.useCallback((photoId: number | null) => { + setLightboxId(photoId); + }, []); const handleModerationAction = React.useCallback( async (action: PhotoModerationAction['action'], photo: TenantPhoto) => { @@ -492,14 +501,14 @@ export default function MobileEventPhotosPage() { const dismissThreshold = 90; if (absY > absX && y > dismissThreshold) { - setLightboxWithUrl(null, { replace: true }); + setLightboxWithUrl(null); return; } if (absX > swipeThreshold) { const nextIndex = x < 0 ? lightboxIndex + 1 : lightboxIndex - 1; if (nextIndex >= 0 && nextIndex < photos.length) { - setLightboxWithUrl(photos[nextIndex]?.id ?? null, { replace: true }); + setLightboxWithUrl(photos[nextIndex]?.id ?? null); } } }, @@ -660,6 +669,12 @@ export default function MobileEventPhotosPage() { {error} + load()} + /> ) : null} @@ -790,11 +805,14 @@ export default function MobileEventPhotosPage() { overflow="hidden" borderWidth={1} borderColor={isSelected ? infoBorder : border} + position="relative" > @@ -821,6 +839,15 @@ export default function MobileEventPhotosPage() { {isSelected ? : null} ) : null} + {!selectionMode ? ( + handleModerationAction(action, photo)} + muted={muted} + surface={surface} + /> + ) : null} ); @@ -947,9 +974,15 @@ export default function MobileEventPhotosPage() { > { + if (lightbox?.thumbnail_url && lightboxImageSrc !== lightbox.thumbnail_url) { + setLightboxImageSrc(lightbox.thumbnail_url); + } + }} /> @@ -1001,7 +1034,7 @@ export default function MobileEventPhotosPage() { setLightboxWithUrl(null, { replace: true })} + onPress={() => setLightboxWithUrl(null)} /> @@ -1239,6 +1272,81 @@ function PhotoSwipeCard({ photo, disabled = false, onOpen, onModerate, children ); } +type PhotoQuickActionsProps = { + photo: TenantPhoto; + disabled?: boolean; + muted: string; + surface: string; + onAction: (action: PhotoModerationAction['action']) => void; +}; + +function PhotoQuickActions({ photo, disabled = false, muted, surface, onAction }: PhotoQuickActionsProps) { + const { t } = useTranslation('management'); + const actionButtons: Array<{ key: PhotoModerationAction['action']; icon: typeof Check; label: string }> = []; + + if (photo.status === 'pending') { + actionButtons.push({ key: 'approve', icon: Check, label: t('photos.actions.approve', 'Approve') }); + actionButtons.push({ key: 'hide', icon: EyeOff, label: t('photos.actions.hide', 'Hide') }); + } else if (photo.status === 'hidden') { + actionButtons.push({ key: 'show', icon: Eye, label: t('photos.actions.show', 'Show') }); + } else { + actionButtons.push({ key: 'hide', icon: EyeOff, label: t('photos.actions.hide', 'Hide') }); + } + + if (photo.status !== 'hidden') { + actionButtons.push({ + key: photo.is_featured ? 'unfeature' : 'feature', + icon: Sparkles, + label: photo.is_featured ? t('photos.actions.unfeature', 'Remove highlight') : t('photos.actions.feature', 'Set highlight'), + }); + } + + if (actionButtons.length === 0) { + return null; + } + + return ( + + {actionButtons.map((action) => ( + { + event.stopPropagation(); + if (!disabled) { + onAction(action.key); + } + }} + > + + + + + ))} + + ); +} + type LimitTranslator = (key: string, options?: Record) => string; function translateLimits(t: (key: string, defaultValue?: string, options?: Record) => string): LimitTranslator { diff --git a/resources/js/admin/mobile/EventTasksPage.tsx b/resources/js/admin/mobile/EventTasksPage.tsx index 444fcbb..a1b22d1 100644 --- a/resources/js/admin/mobile/EventTasksPage.tsx +++ b/resources/js/admin/mobile/EventTasksPage.tsx @@ -453,6 +453,12 @@ export default function MobileEventTasksPage() { {error} + load()} + /> ) : null} diff --git a/resources/js/admin/mobile/NotificationsPage.tsx b/resources/js/admin/mobile/NotificationsPage.tsx index 57d4c50..e1f3ba8 100644 --- a/resources/js/admin/mobile/NotificationsPage.tsx +++ b/resources/js/admin/mobile/NotificationsPage.tsx @@ -504,6 +504,12 @@ export default function MobileNotificationsPage() { {error} + reload()} + /> ) : null} diff --git a/resources/js/admin/mobile/QrPrintPage.tsx b/resources/js/admin/mobile/QrPrintPage.tsx index 16fe56a..7a70e6b 100644 --- a/resources/js/admin/mobile/QrPrintPage.tsx +++ b/resources/js/admin/mobile/QrPrintPage.tsx @@ -38,46 +38,48 @@ export default function MobileQrPrintPage() { const [qrImage, setQrImage] = React.useState(''); const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); - React.useEffect(() => { + const load = React.useCallback(async () => { if (!slug) return; - (async () => { - setLoading(true); - try { - const data = await getEvent(slug); - const invites = await getEventQrInvites(slug); - setEvent(data); - const primaryInvite = invites.find((item) => item.is_active) ?? invites[0] ?? null; - setSelectedInvite(primaryInvite); - const initialLayout = primaryInvite?.layouts?.[0]; - const initialFormat = - initialLayout && ((initialLayout.panel_mode ?? '').toLowerCase() === 'double-mirror' || (initialLayout.orientation ?? '').toLowerCase() === 'landscape') - ? 'a5-foldable' - : 'a4-poster'; - setSelectedFormat(initialFormat); - const resolvedLayoutId = primaryInvite?.layouts - ? resolveLayoutForFormat(initialFormat, primaryInvite.layouts) ?? initialLayout?.id ?? null - : initialLayout?.id ?? null; - setSelectedLayoutId(resolvedLayoutId); - setQrUrl(primaryInvite?.url ?? data.public_url ?? ''); - setQrImage(primaryInvite?.qr_code_data_url ?? ''); - setError(null); - } catch (err) { - if (!isAuthError(err)) { - setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'QR-Daten konnten nicht geladen werden.'))); - } - } finally { - setLoading(false); + setLoading(true); + try { + const data = await getEvent(slug); + const invites = await getEventQrInvites(slug); + setEvent(data); + const primaryInvite = invites.find((item) => item.is_active) ?? invites[0] ?? null; + setSelectedInvite(primaryInvite); + const initialLayout = primaryInvite?.layouts?.[0]; + const initialFormat = + initialLayout && ((initialLayout.panel_mode ?? '').toLowerCase() === 'double-mirror' || (initialLayout.orientation ?? '').toLowerCase() === 'landscape') + ? 'a5-foldable' + : 'a4-poster'; + setSelectedFormat(initialFormat); + const resolvedLayoutId = primaryInvite?.layouts + ? resolveLayoutForFormat(initialFormat, primaryInvite.layouts) ?? initialLayout?.id ?? null + : initialLayout?.id ?? null; + setSelectedLayoutId(resolvedLayoutId); + setQrUrl(primaryInvite?.url ?? data.public_url ?? ''); + setQrImage(primaryInvite?.qr_code_data_url ?? ''); + setError(null); + } catch (err) { + if (!isAuthError(err)) { + setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'QR-Daten konnten nicht geladen werden.'))); } - })(); + } finally { + setLoading(false); + } }, [slug, t]); + React.useEffect(() => { + void load(); + }, [load]); + return ( window.location.reload()} ariaLabel={t('common.refresh', 'Refresh')}> + load()} ariaLabel={t('common.refresh', 'Refresh')}> } @@ -87,6 +89,12 @@ export default function MobileQrPrintPage() { {error} + load()} + /> ) : null} @@ -149,6 +157,15 @@ export default function MobileQrPrintPage() { onPress={async () => { try { const shareUrl = String(qrUrl || (event as any)?.public_url || ''); + if (navigator.share && shareUrl) { + await navigator.share({ + title: t('events.qr.title', 'QR Code & Print Layouts'), + text: t('events.qr.description', 'Scan to access the event guest app.'), + url: shareUrl, + }); + toast.success(t('events.qr.shareSuccess', 'Link geteilt')); + return; + } await navigator.clipboard.writeText(shareUrl); toast.success(t('events.qr.shareSuccess', 'Link kopiert')); } catch { diff --git a/resources/js/admin/mobile/components/MobileShell.tsx b/resources/js/admin/mobile/components/MobileShell.tsx index 4185a04..95ec1ec 100644 --- a/resources/js/admin/mobile/components/MobileShell.tsx +++ b/resources/js/admin/mobile/components/MobileShell.tsx @@ -11,13 +11,15 @@ import { BottomNav, NavKey } from './BottomNav'; import { useMobileNav } from '../hooks/useMobileNav'; import { adminPath } from '../../constants'; import { MobileSheet } from './Sheet'; -import { MobileCard, PillBadge } from './Primitives'; +import { MobileCard, PillBadge, CTAButton } from './Primitives'; import { useNotificationsBadge } from '../hooks/useNotificationsBadge'; import { useOnlineStatus } from '../hooks/useOnlineStatus'; import { formatEventDate, resolveEventDisplayName } from '../../lib/events'; import { TenantEvent, getEvents } from '../../api'; import { withAlpha } from './colors'; import { setTabHistory } from '../lib/tabHistory'; +import { loadPhotoQueue } from '../lib/photoModerationQueue'; +import { countQueuedPhotoActions } from '../lib/queueStatus'; type MobileShellProps = { title?: string; @@ -49,6 +51,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head const [fallbackEvents, setFallbackEvents] = React.useState([]); const [loadingEvents, setLoadingEvents] = React.useState(false); const [attemptedFetch, setAttemptedFetch] = React.useState(false); + const [queuedPhotoCount, setQueuedPhotoCount] = React.useState(0); const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; const effectiveEvents = events.length ? events : fallbackEvents; @@ -88,6 +91,23 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head setTabHistory(activeTab, path); }, [activeTab, location.hash, location.pathname, location.search]); + const refreshQueuedActions = React.useCallback(() => { + const queue = loadPhotoQueue(); + setQueuedPhotoCount(countQueuedPhotoActions(queue, effectiveActive?.slug ?? null)); + }, [effectiveActive?.slug]); + + React.useEffect(() => { + refreshQueuedActions(); + }, [refreshQueuedActions, location.pathname]); + + React.useEffect(() => { + const handleFocus = () => refreshQueuedActions(); + window.addEventListener('focus', handleFocus); + return () => { + window.removeEventListener('focus', handleFocus); + }; + }, [refreshQueuedActions]); + const eventTitle = title ?? (effectiveActive ? resolveEventDisplayName(effectiveActive) : t('header.appName', 'Event Admin')); const subtitleText = subtitle ?? @@ -235,10 +255,30 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head paddingHorizontal="$3" > - {t('mobile.offline', 'Offline mode: changes will sync when you are back online.')} + {t('status.offline', 'Offline mode: changes will sync when you are back online.')} ) : null} + {queuedPhotoCount > 0 ? ( + + + {t('status.queueTitle', 'Photo actions pending')} + + + {online + ? t('status.queueBodyOnline', '{{count}} actions ready to sync.', { count: queuedPhotoCount }) + : t('status.queueBodyOffline', '{{count}} actions saved offline.', { count: queuedPhotoCount })} + + {effectiveActive?.slug ? ( + navigate(adminPath(`/mobile/events/${effectiveActive.slug}/photos`))} + /> + ) : null} + + ) : null} {children} diff --git a/resources/js/admin/mobile/lib/lightboxImage.test.ts b/resources/js/admin/mobile/lib/lightboxImage.test.ts new file mode 100644 index 0000000..c64de88 --- /dev/null +++ b/resources/js/admin/mobile/lib/lightboxImage.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import type { TenantPhoto } from '../../api'; +import { resolveLightboxSources } from './lightboxImage'; + +const basePhoto = (overrides: Partial): TenantPhoto => ({ + id: overrides.id ?? 1, + filename: overrides.filename ?? null, + original_name: overrides.original_name ?? null, + mime_type: overrides.mime_type ?? null, + size: overrides.size ?? 1024, + url: overrides.url ?? null, + thumbnail_url: overrides.thumbnail_url ?? null, + status: overrides.status ?? 'approved', + is_featured: overrides.is_featured ?? false, + likes_count: overrides.likes_count ?? 0, + uploaded_at: overrides.uploaded_at ?? new Date().toISOString(), + uploader_name: overrides.uploader_name ?? null, + ingest_source: overrides.ingest_source ?? null, + caption: overrides.caption ?? null, +}); + +describe('resolveLightboxSources', () => { + it('prefers thumbnail for initial source and keeps full when different', () => { + const photo = basePhoto({ thumbnail_url: '/thumb.jpg', url: '/full.jpg' }); + const sources = resolveLightboxSources(photo); + expect(sources.initial).toBe('/thumb.jpg'); + expect(sources.full).toBe('/full.jpg'); + }); + + it('falls back to full when thumbnail is missing', () => { + const photo = basePhoto({ thumbnail_url: null, url: '/full.jpg' }); + const sources = resolveLightboxSources(photo); + expect(sources.initial).toBe('/full.jpg'); + expect(sources.full).toBe('/full.jpg'); + }); + + it('returns nulls when no sources exist', () => { + const photo = basePhoto({ thumbnail_url: null, url: null }); + const sources = resolveLightboxSources(photo); + expect(sources.initial).toBeNull(); + expect(sources.full).toBeNull(); + }); +}); diff --git a/resources/js/admin/mobile/lib/lightboxImage.ts b/resources/js/admin/mobile/lib/lightboxImage.ts new file mode 100644 index 0000000..604e722 --- /dev/null +++ b/resources/js/admin/mobile/lib/lightboxImage.ts @@ -0,0 +1,16 @@ +import type { TenantPhoto } from '../../api'; + +export type LightboxImageSources = { + initial: string | null; + full: string | null; +}; + +export function resolveLightboxSources(photo: TenantPhoto): LightboxImageSources { + const thumbnail = photo.thumbnail_url ?? null; + const full = photo.url ?? null; + + return { + initial: thumbnail ?? full, + full: full && full !== thumbnail ? full : null, + }; +} diff --git a/resources/js/admin/mobile/lib/queueStatus.test.ts b/resources/js/admin/mobile/lib/queueStatus.test.ts new file mode 100644 index 0000000..16cc42f --- /dev/null +++ b/resources/js/admin/mobile/lib/queueStatus.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import type { PhotoModerationAction } from './photoModerationQueue'; +import { countQueuedPhotoActions } from './queueStatus'; + +const baseAction = (overrides: Partial): PhotoModerationAction => ({ + id: overrides.id ?? '1', + eventSlug: overrides.eventSlug ?? 'event-a', + photoId: overrides.photoId ?? 1, + action: overrides.action ?? 'approve', + createdAt: overrides.createdAt ?? new Date().toISOString(), +}); + +describe('countQueuedPhotoActions', () => { + it('returns total count when slug is not provided', () => { + const queue = [baseAction({ id: '1' }), baseAction({ id: '2', eventSlug: 'event-b' })]; + expect(countQueuedPhotoActions(queue)).toBe(2); + }); + + it('filters by slug when provided', () => { + const queue = [baseAction({ id: '1' }), baseAction({ id: '2', eventSlug: 'event-b' })]; + expect(countQueuedPhotoActions(queue, 'event-a')).toBe(1); + }); +}); diff --git a/resources/js/admin/mobile/lib/queueStatus.ts b/resources/js/admin/mobile/lib/queueStatus.ts new file mode 100644 index 0000000..8b30aa3 --- /dev/null +++ b/resources/js/admin/mobile/lib/queueStatus.ts @@ -0,0 +1,8 @@ +import type { PhotoModerationAction } from './photoModerationQueue'; + +export function countQueuedPhotoActions(queue: PhotoModerationAction[], slug?: string | null): number { + if (!slug) { + return queue.length; + } + return queue.filter((item) => item.eventSlug === slug).length; +} diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index 851773b..a95cb6a 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -179,8 +179,7 @@ export const router = createBrowserRouter([ { path: 'mobile/events/:slug/edit', element: }, { path: 'mobile/events/:slug/qr', element: }, { path: 'mobile/events/:slug/qr/customize/:tokenId?', element: }, - { path: 'mobile/events/:slug/photos', element: }, - { path: 'mobile/events/:slug/photos/:photoId', element: }, + { path: 'mobile/events/:slug/photos/:photoId?', element: }, { path: 'mobile/events/:slug/recap', element: }, { path: 'mobile/events/:slug/members', element: }, { path: 'mobile/events/:slug/tasks', element: },