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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user