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:
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
43
resources/js/admin/mobile/lib/lightboxImage.test.ts
Normal file
43
resources/js/admin/mobile/lib/lightboxImage.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
16
resources/js/admin/mobile/lib/lightboxImage.ts
Normal file
16
resources/js/admin/mobile/lib/lightboxImage.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
23
resources/js/admin/mobile/lib/queueStatus.test.ts
Normal file
23
resources/js/admin/mobile/lib/queueStatus.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
8
resources/js/admin/mobile/lib/queueStatus.ts
Normal file
8
resources/js/admin/mobile/lib/queueStatus.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 /> },
|
||||||
|
|||||||
Reference in New Issue
Block a user