I finished the remaining reliability, sharing, performance, and polish items across the admin

app.
  What’s done
    locales/en/mobile.json and resources/js/admin/i18n/locales/de/mobile.json.
  - Error recovery CTAs on Photos, Notifications, Tasks, and QR screens so users can retry without a full reload in    resources/js/admin/mobile/EventPhotosPage.tsx, resources/js/admin/mobile/NotificationsPage.tsx, resources/js/admin/
    mobile/EventTasksPage.tsx, resources/js/admin/mobile/QrPrintPage.tsx.
  - QR share uses native share sheet when available, with clipboard fallback in resources/js/admin/mobile/
    QrPrintPage.tsx.
  - Lazy‑loaded photo grid thumbnails for better performance in resources/js/admin/mobile/EventPhotosPage.tsx.
  - New helper + tests for queue count logic in resources/js/admin/mobile/lib/queueStatus.ts and resources/js/admin/
    mobile/lib/queueStatus.test.ts.
This commit is contained in:
Codex Agent
2025-12-28 21:29:30 +01:00
parent 1e0c38fce4
commit 9d367512c5
12 changed files with 337 additions and 57 deletions

View File

@@ -24,5 +24,12 @@
"active": "Aktiv", "active": "Aktiv",
"quickQr": "QR öffnen", "quickQr": "QR öffnen",
"clearSelection": "Auswahl entfernen" "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"
} }
} }

View File

@@ -24,5 +24,12 @@
"active": "Active", "active": "Active",
"quickQr": "Quick QR", "quickQr": "Quick QR",
"clearSelection": "Clear selection" "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"
} }
} }

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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 { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
@@ -45,7 +45,7 @@ import {
type PhotoModerationAction, type PhotoModerationAction,
} from './lib/photoModerationQueue'; } from './lib/photoModerationQueue';
import { resolvePhotoSwipeAction, type SwipeModerationAction } from './lib/photoModerationSwipe'; 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'; type FilterKey = 'all' | 'featured' | 'hidden' | 'pending';
@@ -71,6 +71,7 @@ export default function MobileEventPhotosPage() {
const [onlyFeatured, setOnlyFeatured] = React.useState(false); const [onlyFeatured, setOnlyFeatured] = React.useState(false);
const [onlyHidden, setOnlyHidden] = React.useState(false); const [onlyHidden, setOnlyHidden] = React.useState(false);
const [lightboxId, setLightboxId] = React.useState<number | null>(null); const [lightboxId, setLightboxId] = React.useState<number | null>(null);
const [lightboxImageSrc, setLightboxImageSrc] = React.useState<string | null>(null);
const [pendingPhotoId, setPendingPhotoId] = React.useState<number | null>(null); const [pendingPhotoId, setPendingPhotoId] = React.useState<number | null>(null);
const [syncingQueue, setSyncingQueue] = React.useState(false); const [syncingQueue, setSyncingQueue] = React.useState(false);
const [selectionMode, setSelectionMode] = React.useState(false); const [selectionMode, setSelectionMode] = React.useState(false);
@@ -104,7 +105,6 @@ export default function MobileEventPhotosPage() {
return photos.findIndex((photo) => photo.id === lightboxId); return photos.findIndex((photo) => photo.id === lightboxId);
}, [photos, lightboxId]); }, [photos, lightboxId]);
const lightbox = lightboxIndex >= 0 ? photos[lightboxIndex] : null; const lightbox = lightboxIndex >= 0 ? photos[lightboxIndex] : null;
const basePhotosPath = slug ? ADMIN_EVENT_PHOTOS_PATH(slug) : adminPath('/mobile/events');
const parsedPhotoId = React.useMemo(() => { const parsedPhotoId = React.useMemo(() => {
if (!photoIdParam) { if (!photoIdParam) {
return null; return null;
@@ -127,10 +127,29 @@ export default function MobileEventPhotosPage() {
}, [lightboxId]); }, [lightboxId]);
React.useEffect(() => { 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 (parsedPhotoId === null) {
if (!photoIdParam) {
setLightboxId(null);
}
setPendingPhotoId(null); setPendingPhotoId(null);
return; return;
} }
@@ -314,19 +333,9 @@ export default function MobileEventPhotosPage() {
} }
}, [online, syncQueuedActions]); }, [online, syncQueuedActions]);
const setLightboxWithUrl = React.useCallback( const setLightboxWithUrl = React.useCallback((photoId: number | null) => {
(photoId: number | null, options?: { replace?: boolean }) => { setLightboxId(photoId);
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 handleModerationAction = React.useCallback( const handleModerationAction = React.useCallback(
async (action: PhotoModerationAction['action'], photo: TenantPhoto) => { async (action: PhotoModerationAction['action'], photo: TenantPhoto) => {
@@ -492,14 +501,14 @@ export default function MobileEventPhotosPage() {
const dismissThreshold = 90; const dismissThreshold = 90;
if (absY > absX && y > dismissThreshold) { if (absY > absX && y > dismissThreshold) {
setLightboxWithUrl(null, { replace: true }); setLightboxWithUrl(null);
return; return;
} }
if (absX > swipeThreshold) { if (absX > swipeThreshold) {
const nextIndex = x < 0 ? lightboxIndex + 1 : lightboxIndex - 1; const nextIndex = x < 0 ? lightboxIndex + 1 : lightboxIndex - 1;
if (nextIndex >= 0 && nextIndex < photos.length) { 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() {
<Text fontWeight="700" color={danger}> <Text fontWeight="700" color={danger}>
{error} {error}
</Text> </Text>
<CTAButton
label={t('common.retry', 'Retry')}
tone="ghost"
fullWidth={false}
onPress={() => load()}
/>
</MobileCard> </MobileCard>
) : null} ) : null}
@@ -790,11 +805,14 @@ export default function MobileEventPhotosPage() {
overflow="hidden" overflow="hidden"
borderWidth={1} borderWidth={1}
borderColor={isSelected ? infoBorder : border} borderColor={isSelected ? infoBorder : border}
position="relative"
> >
<motion.img <motion.img
layoutId={`photo-${photo.id}`} layoutId={`photo-${photo.id}`}
src={photo.thumbnail_url ?? photo.url ?? undefined} src={photo.thumbnail_url ?? photo.url ?? undefined}
alt={photo.caption ?? 'Photo'} alt={photo.caption ?? 'Photo'}
loading="lazy"
decoding="async"
style={{ width: '100%', height: 110, objectFit: 'cover' }} style={{ width: '100%', height: 110, objectFit: 'cover' }}
/> />
<XStack position="absolute" top={6} left={6} space="$1"> <XStack position="absolute" top={6} left={6} space="$1">
@@ -821,6 +839,15 @@ export default function MobileEventPhotosPage() {
{isSelected ? <Check size={14} color="white" /> : null} {isSelected ? <Check size={14} color="white" /> : null}
</XStack> </XStack>
) : null} ) : null}
{!selectionMode ? (
<PhotoQuickActions
photo={photo}
disabled={busyId === photo.id}
onAction={(action) => handleModerationAction(action, photo)}
muted={muted}
surface={surface}
/>
) : null}
</YStack> </YStack>
</PhotoSwipeCard> </PhotoSwipeCard>
); );
@@ -947,9 +974,15 @@ export default function MobileEventPhotosPage() {
> >
<motion.img <motion.img
layoutId={`photo-${lightbox.id}`} layoutId={`photo-${lightbox.id}`}
src={lightbox.url ?? lightbox.thumbnail_url ?? undefined} src={lightboxImageSrc ?? undefined}
alt={lightbox.caption ?? 'Photo'} alt={lightbox.caption ?? 'Photo'}
loading="eager"
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: backdrop }} style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: backdrop }}
onError={() => {
if (lightbox?.thumbnail_url && lightboxImageSrc !== lightbox.thumbnail_url) {
setLightboxImageSrc(lightbox.thumbnail_url);
}
}}
/> />
</motion.div> </motion.div>
<YStack padding="$3" space="$2"> <YStack padding="$3" space="$2">
@@ -1001,7 +1034,7 @@ export default function MobileEventPhotosPage() {
<CTAButton <CTAButton
label={t('common.close', 'Close')} label={t('common.close', 'Close')}
tone="ghost" tone="ghost"
onPress={() => setLightboxWithUrl(null, { replace: true })} onPress={() => setLightboxWithUrl(null)}
/> />
</YStack> </YStack>
</motion.div> </motion.div>
@@ -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 (
<XStack
position="absolute"
bottom={6}
left={6}
right={6}
paddingHorizontal="$1"
paddingVertical="$1"
borderRadius={12}
backgroundColor="rgba(15, 23, 42, 0.45)"
alignItems="center"
justifyContent="flex-start"
space="$1"
>
{actionButtons.map((action) => (
<Pressable
key={action.key}
disabled={disabled}
aria-label={action.label}
onPress={(event) => {
event.stopPropagation();
if (!disabled) {
onAction(action.key);
}
}}
>
<XStack
alignItems="center"
justifyContent="center"
width={28}
height={28}
borderRadius={999}
backgroundColor={surface}
>
<action.icon size={14} color={muted} />
</XStack>
</Pressable>
))}
</XStack>
);
}
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string; type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator { function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {

View File

@@ -453,6 +453,12 @@ export default function MobileEventTasksPage() {
<Text fontSize={13} fontWeight="600" color={danger}> <Text fontSize={13} fontWeight="600" color={danger}>
{error} {error}
</Text> </Text>
<CTAButton
label={t('common.retry', 'Retry')}
tone="ghost"
fullWidth={false}
onPress={() => load()}
/>
</MobileCard> </MobileCard>
) : null} ) : null}

View File

@@ -504,6 +504,12 @@ export default function MobileNotificationsPage() {
<Text fontWeight="700" color={errorText}> <Text fontWeight="700" color={errorText}>
{error} {error}
</Text> </Text>
<CTAButton
label={t('common.retry', 'Retry')}
tone="ghost"
fullWidth={false}
onPress={() => reload()}
/>
</MobileCard> </MobileCard>
) : null} ) : null}

View File

@@ -38,46 +38,48 @@ export default function MobileQrPrintPage() {
const [qrImage, setQrImage] = React.useState<string>(''); const [qrImage, setQrImage] = React.useState<string>('');
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
React.useEffect(() => { const load = React.useCallback(async () => {
if (!slug) return; if (!slug) return;
(async () => { setLoading(true);
setLoading(true); try {
try { const data = await getEvent(slug);
const data = await getEvent(slug); const invites = await getEventQrInvites(slug);
const invites = await getEventQrInvites(slug); setEvent(data);
setEvent(data); const primaryInvite = invites.find((item) => item.is_active) ?? invites[0] ?? null;
const primaryInvite = invites.find((item) => item.is_active) ?? invites[0] ?? null; setSelectedInvite(primaryInvite);
setSelectedInvite(primaryInvite); const initialLayout = primaryInvite?.layouts?.[0];
const initialLayout = primaryInvite?.layouts?.[0]; const initialFormat =
const initialFormat = initialLayout && ((initialLayout.panel_mode ?? '').toLowerCase() === 'double-mirror' || (initialLayout.orientation ?? '').toLowerCase() === 'landscape')
initialLayout && ((initialLayout.panel_mode ?? '').toLowerCase() === 'double-mirror' || (initialLayout.orientation ?? '').toLowerCase() === 'landscape') ? 'a5-foldable'
? 'a5-foldable' : 'a4-poster';
: 'a4-poster'; setSelectedFormat(initialFormat);
setSelectedFormat(initialFormat); const resolvedLayoutId = primaryInvite?.layouts
const resolvedLayoutId = primaryInvite?.layouts ? resolveLayoutForFormat(initialFormat, primaryInvite.layouts) ?? initialLayout?.id ?? null
? resolveLayoutForFormat(initialFormat, primaryInvite.layouts) ?? initialLayout?.id ?? null : initialLayout?.id ?? null;
: initialLayout?.id ?? null; setSelectedLayoutId(resolvedLayoutId);
setSelectedLayoutId(resolvedLayoutId); setQrUrl(primaryInvite?.url ?? data.public_url ?? '');
setQrUrl(primaryInvite?.url ?? data.public_url ?? ''); setQrImage(primaryInvite?.qr_code_data_url ?? '');
setQrImage(primaryInvite?.qr_code_data_url ?? ''); setError(null);
setError(null); } catch (err) {
} catch (err) { if (!isAuthError(err)) {
if (!isAuthError(err)) { setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'QR-Daten konnten nicht geladen werden.')));
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'QR-Daten konnten nicht geladen werden.')));
}
} finally {
setLoading(false);
} }
})(); } finally {
setLoading(false);
}
}, [slug, t]); }, [slug, t]);
React.useEffect(() => {
void load();
}, [load]);
return ( return (
<MobileShell <MobileShell
activeTab="home" activeTab="home"
title={t('events.qr.title', 'QR Code & Print Layouts')} title={t('events.qr.title', 'QR Code & Print Layouts')}
onBack={back} onBack={back}
headerActions={ headerActions={
<HeaderActionButton onPress={() => window.location.reload()} ariaLabel={t('common.refresh', 'Refresh')}> <HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" /> <RefreshCcw size={18} color="#0f172a" />
</HeaderActionButton> </HeaderActionButton>
} }
@@ -87,6 +89,12 @@ export default function MobileQrPrintPage() {
<Text fontWeight="700" color="#b91c1c"> <Text fontWeight="700" color="#b91c1c">
{error} {error}
</Text> </Text>
<CTAButton
label={t('common.retry', 'Retry')}
tone="ghost"
fullWidth={false}
onPress={() => load()}
/>
</MobileCard> </MobileCard>
) : null} ) : null}
@@ -149,6 +157,15 @@ export default function MobileQrPrintPage() {
onPress={async () => { onPress={async () => {
try { try {
const shareUrl = String(qrUrl || (event as any)?.public_url || ''); 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); await navigator.clipboard.writeText(shareUrl);
toast.success(t('events.qr.shareSuccess', 'Link kopiert')); toast.success(t('events.qr.shareSuccess', 'Link kopiert'));
} catch { } catch {

View File

@@ -11,13 +11,15 @@ import { BottomNav, NavKey } from './BottomNav';
import { useMobileNav } from '../hooks/useMobileNav'; import { useMobileNav } from '../hooks/useMobileNav';
import { adminPath } from '../../constants'; import { adminPath } from '../../constants';
import { MobileSheet } from './Sheet'; import { MobileSheet } from './Sheet';
import { MobileCard, PillBadge } from './Primitives'; import { MobileCard, PillBadge, CTAButton } from './Primitives';
import { useNotificationsBadge } from '../hooks/useNotificationsBadge'; import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
import { useOnlineStatus } from '../hooks/useOnlineStatus'; import { useOnlineStatus } from '../hooks/useOnlineStatus';
import { formatEventDate, resolveEventDisplayName } from '../../lib/events'; import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
import { TenantEvent, getEvents } from '../../api'; import { TenantEvent, getEvents } from '../../api';
import { withAlpha } from './colors'; import { withAlpha } from './colors';
import { setTabHistory } from '../lib/tabHistory'; import { setTabHistory } from '../lib/tabHistory';
import { loadPhotoQueue } from '../lib/photoModerationQueue';
import { countQueuedPhotoActions } from '../lib/queueStatus';
type MobileShellProps = { type MobileShellProps = {
title?: string; title?: string;
@@ -49,6 +51,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]); const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [loadingEvents, setLoadingEvents] = React.useState(false); const [loadingEvents, setLoadingEvents] = React.useState(false);
const [attemptedFetch, setAttemptedFetch] = 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 locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const effectiveEvents = events.length ? events : fallbackEvents; const effectiveEvents = events.length ? events : fallbackEvents;
@@ -88,6 +91,23 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
setTabHistory(activeTab, path); setTabHistory(activeTab, path);
}, [activeTab, location.hash, location.pathname, location.search]); }, [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 eventTitle = title ?? (effectiveActive ? resolveEventDisplayName(effectiveActive) : t('header.appName', 'Event Admin'));
const subtitleText = const subtitleText =
subtitle ?? subtitle ??
@@ -235,10 +255,30 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
paddingHorizontal="$3" paddingHorizontal="$3"
> >
<Text fontSize="$xs" fontWeight="700" color={warningText}> <Text fontSize="$xs" fontWeight="700" color={warningText}>
{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.')}
</Text> </Text>
</XStack> </XStack>
) : null} ) : null}
{queuedPhotoCount > 0 ? (
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="700" color={textColor}>
{t('status.queueTitle', 'Photo actions pending')}
</Text>
<Text fontSize="$xs" color={mutedText}>
{online
? t('status.queueBodyOnline', '{{count}} actions ready to sync.', { count: queuedPhotoCount })
: t('status.queueBodyOffline', '{{count}} actions saved offline.', { count: queuedPhotoCount })}
</Text>
{effectiveActive?.slug ? (
<CTAButton
label={t('status.queueAction', 'Open Photos')}
tone="ghost"
fullWidth={false}
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive.slug}/photos`))}
/>
) : null}
</MobileCard>
) : null}
{children} {children}
</YStack> </YStack>

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import type { TenantPhoto } from '../../api';
import { resolveLightboxSources } from './lightboxImage';
const basePhoto = (overrides: Partial<TenantPhoto>): 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();
});
});

View File

@@ -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,
};
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import type { PhotoModerationAction } from './photoModerationQueue';
import { countQueuedPhotoActions } from './queueStatus';
const baseAction = (overrides: Partial<PhotoModerationAction>): 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);
});
});

View File

@@ -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;
}

View File

@@ -179,8 +179,7 @@ export const router = createBrowserRouter([
{ path: 'mobile/events/:slug/edit', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/edit', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/qr', element: <RequireAdminAccess><MobileQrPrintPage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/qr', element: <RequireAdminAccess><MobileQrPrintPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/qr/customize/:tokenId?', element: <RequireAdminAccess><MobileQrLayoutCustomizePage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/qr/customize/:tokenId?', element: <RequireAdminAccess><MobileQrLayoutCustomizePage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/photos', element: <MobileEventPhotosPage /> }, { path: 'mobile/events/:slug/photos/:photoId?', element: <MobileEventPhotosPage /> },
{ path: 'mobile/events/:slug/photos/:photoId', element: <MobileEventPhotosPage /> },
{ path: 'mobile/events/:slug/recap', element: <RequireAdminAccess><MobileEventRecapPage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/recap', element: <RequireAdminAccess><MobileEventRecapPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> }, { path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },