Files
fotospiel-app/resources/js/admin/mobile/components/MobileShell.tsx
Codex Agent 9d367512c5 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.
2025-12-28 21:29:30 +01:00

400 lines
15 KiB
TypeScript

import React, { Suspense } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ChevronDown, ChevronLeft, Bell, QrCode } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@tamagui/core';
import { useEventContext } from '../../context/EventContext';
import { BottomNav, NavKey } from './BottomNav';
import { useMobileNav } from '../hooks/useMobileNav';
import { adminPath } from '../../constants';
import { MobileSheet } from './Sheet';
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;
subtitle?: string;
children: React.ReactNode;
activeTab: NavKey;
onBack?: () => void;
headerActions?: React.ReactNode;
};
export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) {
const { events, activeEvent, hasMultipleEvents, hasEvents, selectEvent } = useEventContext();
const { go } = useMobileNav(activeEvent?.slug);
const navigate = useNavigate();
const location = useLocation();
const { t, i18n } = useTranslation('mobile');
const { count: notificationCount } = useNotificationsBadge();
const online = useOnlineStatus();
const theme = useTheme();
const backgroundColor = String(theme.background?.val ?? '#f7f8fb');
const surfaceColor = String(theme.surface?.val ?? '#ffffff');
const borderColor = String(theme.borderColor?.val ?? '#e5e7eb');
const textColor = String(theme.color?.val ?? '#111827');
const mutedText = String(theme.gray?.val ?? '#6b7280');
const warningBg = String(theme.yellow3?.val ?? '#fef3c7');
const warningText = String(theme.yellow11?.val ?? '#92400e');
const headerSurface = withAlpha(surfaceColor, 0.94);
const [pickerOpen, setPickerOpen] = React.useState(false);
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
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;
const effectiveHasMultiple = hasMultipleEvents || effectiveEvents.length > 1;
const effectiveHasEvents = hasEvents || effectiveEvents.length > 0;
const effectiveActive = activeEvent ?? (effectiveEvents.length === 1 ? effectiveEvents[0] : null);
React.useEffect(() => {
if (events.length || loadingEvents || attemptedFetch) {
return;
}
setAttemptedFetch(true);
setLoadingEvents(true);
getEvents({ force: true })
.then((list) => {
setFallbackEvents(list ?? []);
if (!activeEvent && list?.length === 1) {
selectEvent(list[0]?.slug ?? null);
}
})
.catch(() => setFallbackEvents([]))
.finally(() => setLoadingEvents(false));
}, [events.length, loadingEvents, attemptedFetch, activeEvent, selectEvent]);
React.useEffect(() => {
if (!pickerOpen) return;
if (effectiveEvents.length) return;
setLoadingEvents(true);
getEvents({ force: true })
.then((list) => setFallbackEvents(list ?? []))
.catch(() => setFallbackEvents([]))
.finally(() => setLoadingEvents(false));
}, [pickerOpen, effectiveEvents.length]);
React.useEffect(() => {
const path = `${location.pathname}${location.search}${location.hash}`;
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 ??
(effectiveActive?.event_date
? formatEventDate(effectiveActive.event_date, locale) ?? ''
: effectiveHasEvents
? t('header.selectEvent', 'Select an event to continue')
: t('header.empty', 'Create your first event to get started'));
const showEventSwitcher = effectiveHasMultiple;
const showQr = Boolean(effectiveActive?.slug);
return (
<YStack backgroundColor={backgroundColor} minHeight="100vh" alignItems="center">
<YStack
backgroundColor={headerSurface}
borderBottomWidth={1}
borderColor={borderColor}
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom="$3"
shadowColor="#0f172a"
shadowOpacity={0.06}
shadowRadius={10}
shadowOffset={{ width: 0, height: 4 }}
width="100%"
maxWidth={800}
position="sticky"
top={0}
zIndex={60}
style={{
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
}}
>
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
{onBack ? (
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={28} color="#007AFF" strokeWidth={2.5} />
</XStack>
</HeaderActionButton>
) : (
<XStack width={28} />
)}
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end">
<XStack alignItems="center" space="$1" maxWidth="55%">
<Pressable
disabled={!showEventSwitcher}
onPress={() => setPickerOpen(true)}
style={{ alignItems: 'flex-end' }}
>
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
{eventTitle}
</Text>
{subtitleText ? (
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
{subtitleText}
</Text>
) : null}
</Pressable>
{showEventSwitcher ? <ChevronDown size={14} color={textColor} /> : null}
</XStack>
<XStack alignItems="center" space="$2">
<HeaderActionButton
onPress={() => navigate(adminPath('/mobile/notifications'))}
ariaLabel={t('mobile.notifications', 'Notifications')}
>
<XStack
width={34}
height={34}
borderRadius={12}
backgroundColor={surfaceColor}
alignItems="center"
justifyContent="center"
position="relative"
>
<Bell size={16} color={textColor} />
{notificationCount > 0 ? (
<YStack
position="absolute"
top={-4}
right={-4}
minWidth={18}
height={18}
paddingHorizontal={6}
borderRadius={999}
backgroundColor="#ef4444"
alignItems="center"
justifyContent="center"
>
<Text fontSize={10} color="white" fontWeight="700">
{notificationCount > 9 ? '9+' : notificationCount}
</Text>
</YStack>
) : null}
</XStack>
</HeaderActionButton>
{showQr ? (
<HeaderActionButton
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}
ariaLabel={t('header.quickQr', 'Quick QR')}
>
<XStack
height={34}
paddingHorizontal="$3"
borderRadius={12}
backgroundColor="#0ea5e9"
alignItems="center"
justifyContent="center"
space="$1.5"
>
<QrCode size={16} color="white" />
<Text fontSize="$xs" fontWeight="800" color="white">
{t('header.quickQr', 'Quick QR')}
</Text>
</XStack>
</HeaderActionButton>
) : null}
{headerActions ?? null}
</XStack>
</XStack>
</XStack>
</YStack>
<YStack
flex={1}
padding="$4"
paddingBottom="$10"
space="$3"
width="100%"
maxWidth={800}
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)' }}
>
{!online ? (
<XStack
alignItems="center"
justifyContent="center"
borderRadius={12}
backgroundColor={warningBg}
paddingVertical="$2"
paddingHorizontal="$3"
>
<Text fontSize="$xs" fontWeight="700" color={warningText}>
{t('status.offline', 'Offline mode: changes will sync when you are back online.')}
</Text>
</XStack>
) : 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}
</YStack>
<BottomNav active={activeTab} onNavigate={go} />
<MobileSheet
open={pickerOpen}
onClose={() => setPickerOpen(false)}
title={t('header.eventSwitcher', 'Choose an event')}
footer={null}
bottomOffsetPx={110}
>
<YStack space="$2">
{effectiveEvents.length === 0 ? (
<MobileCard alignItems="flex-start" space="$2">
<Text fontSize="$sm" color={textColor} fontWeight="700">
{t('header.noEventsTitle', 'Create your first event')}
</Text>
<Text fontSize="$xs" color={mutedText}>
{t('header.noEventsBody', 'Start an event to access tasks, uploads, QR posters and more.')}
</Text>
<Pressable onPress={() => navigate(adminPath('/mobile/events/new'))}>
<XStack alignItems="center" space="$2">
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
{t('header.createEvent', 'Create event')}
</Text>
</XStack>
</Pressable>
</MobileCard>
) : (
effectiveEvents.map((event) => (
<Pressable
key={event.slug}
onPress={() => {
const targetSlug = event.slug ?? null;
selectEvent(targetSlug);
setPickerOpen(false);
if (targetSlug) {
navigate(adminPath(`/mobile/events/${targetSlug}`));
}
}}
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<YStack space="$0.5">
<Text fontSize="$sm" fontWeight="700" color={textColor}>
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color={mutedText}>
{formatEventDate(event.event_date, locale) ?? t('header.noDate', 'Date tbd')}
</Text>
</YStack>
<PillBadge tone={event.slug === activeEvent?.slug ? 'success' : 'muted'}>
{event.slug === activeEvent?.slug
? t('header.active', 'Active')
: (event.status ?? '—')}
</PillBadge>
</XStack>
</Pressable>
))
)}
{activeEvent ? (
<Pressable
onPress={() => {
selectEvent(null);
setPickerOpen(false);
}}
>
<Text fontSize="$xs" color="#6b7280" textAlign="center">
{t('header.clearSelection', 'Clear selection')}
</Text>
</Pressable>
) : null}
</YStack>
</MobileSheet>
</YStack>
);
}
export function HeaderActionButton({
onPress,
children,
ariaLabel,
}: {
onPress: () => void;
children: React.ReactNode;
ariaLabel?: string;
}) {
const [pressed, setPressed] = React.useState(false);
return (
<Pressable
onPress={onPress}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
onPointerLeave={() => setPressed(false)}
aria-label={ariaLabel}
style={{
transform: pressed ? 'scale(0.96)' : 'scale(1)',
opacity: pressed ? 0.86 : 1,
transition: 'transform 120ms ease, opacity 120ms ease',
}}
>
{children}
</Pressable>
);
}
export function renderEventLocation(event?: TenantEvent | null): string {
if (!event) return 'Location';
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =
(settings.location as string | undefined) ??
(settings.address as string | undefined) ??
(settings.city as string | undefined);
if (candidate && candidate.trim()) {
return candidate;
}
return 'Location';
}