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

@@ -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<number | null>(null);
const [lightboxImageSrc, setLightboxImageSrc] = React.useState<string | null>(null);
const [pendingPhotoId, setPendingPhotoId] = React.useState<number | null>(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() {
<Text fontWeight="700" color={danger}>
{error}
</Text>
<CTAButton
label={t('common.retry', 'Retry')}
tone="ghost"
fullWidth={false}
onPress={() => load()}
/>
</MobileCard>
) : null}
@@ -790,11 +805,14 @@ export default function MobileEventPhotosPage() {
overflow="hidden"
borderWidth={1}
borderColor={isSelected ? infoBorder : border}
position="relative"
>
<motion.img
layoutId={`photo-${photo.id}`}
src={photo.thumbnail_url ?? photo.url ?? undefined}
alt={photo.caption ?? 'Photo'}
loading="lazy"
decoding="async"
style={{ width: '100%', height: 110, objectFit: 'cover' }}
/>
<XStack position="absolute" top={6} left={6} space="$1">
@@ -821,6 +839,15 @@ export default function MobileEventPhotosPage() {
{isSelected ? <Check size={14} color="white" /> : null}
</XStack>
) : null}
{!selectionMode ? (
<PhotoQuickActions
photo={photo}
disabled={busyId === photo.id}
onAction={(action) => handleModerationAction(action, photo)}
muted={muted}
surface={surface}
/>
) : null}
</YStack>
</PhotoSwipeCard>
);
@@ -947,9 +974,15 @@ export default function MobileEventPhotosPage() {
>
<motion.img
layoutId={`photo-${lightbox.id}`}
src={lightbox.url ?? lightbox.thumbnail_url ?? undefined}
src={lightboxImageSrc ?? undefined}
alt={lightbox.caption ?? 'Photo'}
loading="eager"
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: backdrop }}
onError={() => {
if (lightbox?.thumbnail_url && lightboxImageSrc !== lightbox.thumbnail_url) {
setLightboxImageSrc(lightbox.thumbnail_url);
}
}}
/>
</motion.div>
<YStack padding="$3" space="$2">
@@ -1001,7 +1034,7 @@ export default function MobileEventPhotosPage() {
<CTAButton
label={t('common.close', 'Close')}
tone="ghost"
onPress={() => setLightboxWithUrl(null, { replace: true })}
onPress={() => setLightboxWithUrl(null)}
/>
</YStack>
</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;
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {