Files
fotospiel-app/resources/js/admin/mobile/EventPhotosPage.tsx
Codex Agent 22cb7ed7ce
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
fix: resolve typescript and build errors across admin and guest apps
2026-01-07 13:25:30 +01:00

1538 lines
53 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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';
import { AnimatePresence, motion, useAnimationControls, type PanInfo } from 'framer-motion';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, PillBadge, CTAButton, SkeletonCard } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
import {
getEventPhotos,
getEventPhoto,
updatePhotoVisibility,
featurePhoto,
unfeaturePhoto,
updatePhotoStatus,
TenantPhoto,
EventAddonCatalogItem,
createEventAddonCheckout,
getAddonCatalog,
EventAddonSummary,
EventLimitSummary,
getEvent,
} from '../api';
import toast from 'react-hot-toast';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { MobileSheet } from './components/Sheet';
import { useEventContext } from '../context/EventContext';
import { useAdminTheme } from './theme';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { adminPath } from '../constants';
import { scopeDefaults, selectAddonKeyForScope } from './addons';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import { triggerHaptic } from './lib/haptics';
import { useOnlineStatus } from './hooks/useOnlineStatus';
import { useBackNavigation } from './hooks/useBackNavigation';
import {
enqueuePhotoAction,
loadPhotoQueue,
removePhotoAction,
replacePhotoQueue,
type PhotoModerationAction,
} from './lib/photoModerationQueue';
import { resolvePhotoSwipeAction, type SwipeModerationAction } from './lib/photoModerationSwipe';
import { resolveLightboxSources } from './lib/lightboxImage';
type FilterKey = 'all' | 'featured' | 'hidden' | 'pending';
export default function MobileEventPhotosPage() {
const { slug: slugParam, photoId: photoIdParam } = useParams<{ slug?: string; photoId?: string }>();
const { activeEvent, selectEvent } = useEventContext();
const slug = slugParam ?? activeEvent?.slug ?? null;
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation('management');
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
const [filter, setFilter] = React.useState<FilterKey>('all');
const [page, setPage] = React.useState(1);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [busyId, setBusyId] = React.useState<number | null>(null);
const [totalCount, setTotalCount] = React.useState<number>(0);
const [hasMore, setHasMore] = React.useState(false);
const [search, setSearch] = React.useState('');
const [showFilters, setShowFilters] = React.useState(false);
const [uploaderFilter, setUploaderFilter] = React.useState('');
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);
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
const [bulkBusy, setBulkBusy] = React.useState(false);
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]);
const [busyScope, setBusyScope] = React.useState<string | null>(null);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const [consentOpen, setConsentOpen] = React.useState(false);
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests'; addonKey: string } | null>(null);
const [consentBusy, setConsentBusy] = React.useState(false);
const [queuedActions, setQueuedActions] = React.useState<PhotoModerationAction[]>(() => loadPhotoQueue());
const online = useOnlineStatus();
const syncingQueueRef = React.useRef(false);
const { text, muted, border, accentSoft, accent, danger, surface, backdrop, primary } = useAdminTheme();
const infoBg = accentSoft;
const infoBorder = accent;
const basePhotosPath = slug ? adminPath(`/mobile/events/${slug}/photos`) : adminPath('/mobile/events');
const photoQuery = React.useMemo(() => {
return new URLSearchParams(location.search).get('photo');
}, [location.search]);
const sourcePhotoParam = photoQuery ?? photoIdParam ?? null;
const lightboxIndex = React.useMemo(() => {
if (lightboxId === null) {
return -1;
}
return photos.findIndex((photo) => photo.id === lightboxId);
}, [photos, lightboxId]);
const lightbox = lightboxIndex >= 0 ? photos[lightboxIndex] : null;
const parsedPhotoId = React.useMemo(() => {
if (!sourcePhotoParam) {
return null;
}
const parsed = Number(sourcePhotoParam);
return Number.isFinite(parsed) ? parsed : null;
}, [sourcePhotoParam]);
React.useEffect(() => {
if (lightboxId !== null && lightboxIndex === -1 && !loading && pendingPhotoId !== lightboxId) {
setLightboxId(null);
}
}, [lightboxId, lightboxIndex, loading, pendingPhotoId]);
React.useEffect(() => {
if (lightboxId !== null) {
setSelectionMode(false);
setSelectedIds([]);
}
}, [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 (!sourcePhotoParam) {
setLightboxId(null);
setPendingPhotoId(null);
return;
}
if (parsedPhotoId === null) {
setPendingPhotoId(null);
return;
}
setLightboxId(parsedPhotoId);
setPendingPhotoId(parsedPhotoId);
}, [parsedPhotoId, sourcePhotoParam]);
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam);
}
}, [slugParam, activeEvent?.slug, selectEvent]);
const load = React.useCallback(async () => {
if (!slug) return;
setLoading(true);
setError(null);
try {
const status =
filter === 'hidden' || onlyHidden
? 'hidden'
: filter === 'pending'
? 'pending'
: undefined;
const result = await getEventPhotos(slug, {
page,
perPage: 20,
sort: 'desc',
featured: filter === 'featured' || onlyFeatured,
status,
search: search || undefined,
});
setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos]));
setTotalCount(result.meta?.total ?? result.photos.length);
setLimits(result.limits ?? null);
const lastPage = result.meta?.last_page ?? 1;
setHasMore(page < lastPage);
const [addons, event] = await Promise.all([
getAddonCatalog().catch(() => [] as EventAddonCatalogItem[]),
getEvent(slug).catch(() => null),
]);
setCatalogAddons(addons ?? []);
setEventAddons(event?.addons ?? []);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.')));
}
} finally {
setLoading(false);
}
}, [slug, filter, t, page, onlyFeatured, onlyHidden, search]);
React.useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
if (!location.search || !slug) return;
const params = new URLSearchParams(location.search);
if (params.get('addon_success')) {
toast.success(t('mobileBilling.addonApplied', 'Add-on applied. Limits update shortly.'));
setPage(1);
void load();
params.delete('addon_success');
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true });
}
}, [location.search, slug, load, navigate, t, location.pathname]);
React.useEffect(() => {
setPage(1);
}, [filter, slug]);
const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => {
replacePhotoQueue(queue);
setQueuedActions(queue);
}, []);
const applyOptimisticUpdate = React.useCallback((photoId: number, action: PhotoModerationAction['action']) => {
setPhotos((prev) =>
prev.map((photo) => {
if (photo.id !== photoId) {
return photo;
}
if (action === 'approve') {
return { ...photo, status: 'approved' };
}
if (action === 'hide') {
return { ...photo, status: 'hidden' };
}
if (action === 'show') {
return { ...photo, status: 'approved' };
}
if (action === 'feature') {
return { ...photo, is_featured: true };
}
if (action === 'unfeature') {
return { ...photo, is_featured: false };
}
return photo;
}),
);
}, []);
const enqueueModerationAction = React.useCallback(
(action: PhotoModerationAction['action'], photoId: number) => {
if (!slug) {
return;
}
const nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId, action });
setQueuedActions(nextQueue);
applyOptimisticUpdate(photoId, action);
toast.success(t('mobilePhotos.queued', 'Aktion gespeichert. Wird synchronisiert, sobald du online bist.'));
triggerHaptic('selection');
},
[applyOptimisticUpdate, slug, t],
);
const syncQueuedActions = React.useCallback(
async (options?: { silent?: boolean }) => {
if (!online || syncingQueueRef.current) {
return;
}
const queue = loadPhotoQueue();
if (queue.length === 0) {
return;
}
syncingQueueRef.current = true;
setSyncingQueue(true);
let remaining = queue;
for (const entry of queue) {
try {
let updated: TenantPhoto | null = null;
if (entry.action === 'approve') {
updated = await updatePhotoStatus(entry.eventSlug, entry.photoId, 'approved');
} else if (entry.action === 'hide') {
updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, true);
} else if (entry.action === 'show') {
updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, false);
} else if (entry.action === 'feature') {
updated = await featurePhoto(entry.eventSlug, entry.photoId);
} else if (entry.action === 'unfeature') {
updated = await unfeaturePhoto(entry.eventSlug, entry.photoId);
}
remaining = removePhotoAction(remaining, entry.id);
if (updated && entry.eventSlug === slug) {
setPhotos((prev) => prev.map((photo) => (photo.id === updated!.id ? updated! : photo)));
}
} catch (err) {
if (!options?.silent) {
toast.error(t('mobilePhotos.syncFailed', 'Synchronisierung fehlgeschlagen. Bitte später erneut versuchen.'));
}
if (isAuthError(err)) {
break;
}
}
}
updateQueueState(remaining);
setSyncingQueue(false);
syncingQueueRef.current = false;
},
[online, slug, t, updateQueueState],
);
React.useEffect(() => {
if (online) {
void syncQueuedActions({ silent: true });
}
}, [online, syncQueuedActions]);
const setLightboxWithUrl = React.useCallback(
(photoId: number | null) => {
setLightboxId(photoId);
if (typeof window === 'undefined' || !slug) {
return;
}
const params = new URLSearchParams(window.location.search);
if (photoId) {
params.set('photo', String(photoId));
} else {
params.delete('photo');
}
const nextSearch = params.toString();
const nextPath = `${basePhotosPath}${nextSearch ? `?${nextSearch}` : ''}`;
if (`${window.location.pathname}${window.location.search}` !== nextPath) {
window.history.replaceState(null, '', nextPath);
}
},
[basePhotosPath, slug],
);
const handleModerationAction = React.useCallback(
async (action: PhotoModerationAction['action'], photo: TenantPhoto) => {
if (!slug) {
return;
}
if (!online) {
enqueueModerationAction(action, photo.id);
return;
}
setBusyId(photo.id);
const successMessage = () => {
if (action === 'approve') {
return t('mobilePhotos.approveSuccess', 'Photo approved');
}
if (action === 'hide') {
return t('mobilePhotos.hideSuccess', 'Photo hidden');
}
if (action === 'show') {
return t('mobilePhotos.showSuccess', 'Photo shown');
}
if (action === 'feature') {
return t('mobilePhotos.featureSuccess', 'Als Highlight markiert');
}
return t('mobilePhotos.unfeatureSuccess', 'Highlight entfernt');
};
const errorMessage = () => {
if (action === 'approve') {
return t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.');
}
if (action === 'hide' || action === 'show') {
return t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.');
}
return t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.');
};
try {
let updated: TenantPhoto;
if (action === 'approve') {
updated = await updatePhotoStatus(slug, photo.id, 'approved');
} else if (action === 'hide') {
updated = await updatePhotoVisibility(slug, photo.id, true);
} else if (action === 'show') {
updated = await updatePhotoVisibility(slug, photo.id, false);
} else if (action === 'feature') {
updated = await featurePhoto(slug, photo.id);
} else {
updated = await unfeaturePhoto(slug, photo.id);
}
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
toast.success(successMessage());
triggerHaptic(action === 'approve' ? 'success' : 'medium');
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, errorMessage()));
toast.error(errorMessage());
}
} finally {
setBusyId(null);
}
},
[enqueueModerationAction, online, slug, t],
);
React.useEffect(() => {
if (!slug || pendingPhotoId === null) {
return;
}
if (photos.some((photo) => photo.id === pendingPhotoId)) {
setPendingPhotoId(null);
return;
}
if (loading) {
return;
}
let active = true;
void (async () => {
try {
const fetched = await getEventPhoto(slug, pendingPhotoId);
if (!active) {
return;
}
setPhotos((prev) => {
if (prev.some((photo) => photo.id === fetched.id)) {
return prev;
}
return [fetched, ...prev];
});
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.'));
}
if (active) {
setLightboxWithUrl(null);
}
} finally {
if (active) {
setPendingPhotoId(null);
}
}
})();
return () => {
active = false;
};
}, [pendingPhotoId, slug, photos, loading, t, setLightboxWithUrl]);
async function toggleVisibility(photo: TenantPhoto) {
const action = photo.status === 'hidden' ? 'show' : 'hide';
await handleModerationAction(action, photo);
}
async function toggleFeature(photo: TenantPhoto) {
const action = photo.is_featured ? 'unfeature' : 'feature';
await handleModerationAction(action, photo);
}
async function approvePhoto(photo: TenantPhoto) {
await handleModerationAction('approve', photo);
}
const selectedPhotos = React.useMemo(
() => photos.filter((photo) => selectedIds.includes(photo.id)),
[photos, selectedIds],
);
const hasPendingSelection = selectedPhotos.some((photo) => photo.status === 'pending');
const hasHiddenSelection = selectedPhotos.some((photo) => photo.status === 'hidden');
const hasVisibleSelection = selectedPhotos.some((photo) => photo.status !== 'hidden');
const hasFeaturedSelection = selectedPhotos.some((photo) => photo.is_featured);
const hasUnfeaturedSelection = selectedPhotos.some((photo) => !photo.is_featured);
const queuedEventCount = React.useMemo(() => {
if (!slug) {
return queuedActions.length;
}
return queuedActions.filter((action) => action.eventSlug === slug).length;
}, [queuedActions, slug]);
function toggleSelection(id: number) {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]));
}
function clearSelection() {
setSelectedIds([]);
setSelectionMode(false);
}
const handleLightboxDragEnd = React.useCallback(
(_event: PointerEvent, info: { offset: { x: number; y: number } }) => {
if (lightboxIndex < 0) {
return;
}
const { x, y } = info.offset;
const absX = Math.abs(x);
const absY = Math.abs(y);
const swipeThreshold = 80;
const dismissThreshold = 90;
if (absY > absX && y > dismissThreshold) {
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);
}
}
},
[lightboxIndex, photos, setLightboxWithUrl],
);
async function applyBulkAction(action: 'approve' | 'hide' | 'show' | 'feature' | 'unfeature') {
if (!slug || bulkBusy || selectedPhotos.length === 0) return;
setBulkBusy(true);
const targets = selectedPhotos.filter((photo) => {
if (action === 'approve') return photo.status === 'pending';
if (action === 'hide') return photo.status !== 'hidden';
if (action === 'show') return photo.status === 'hidden';
if (action === 'feature') return !photo.is_featured;
if (action === 'unfeature') return photo.is_featured;
return false;
});
if (targets.length === 0) {
setBulkBusy(false);
return;
}
if (!online) {
let nextQueue: PhotoModerationAction[] = [];
targets.forEach((photo) => {
nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId: photo.id, action });
applyOptimisticUpdate(photo.id, action);
});
setQueuedActions(nextQueue);
toast.success(t('mobilePhotos.queued', 'Aktion gespeichert. Wird synchronisiert, sobald du online bist.'));
triggerHaptic('selection');
setBulkBusy(false);
return;
}
try {
const results = await Promise.allSettled(
targets.map(async (photo) => {
if (action === 'approve') {
return await updatePhotoStatus(slug, photo.id, 'approved');
}
if (action === 'hide') {
return await updatePhotoVisibility(slug, photo.id, true);
}
if (action === 'show') {
return await updatePhotoVisibility(slug, photo.id, false);
}
if (action === 'feature') {
return await featurePhoto(slug, photo.id);
}
return await unfeaturePhoto(slug, photo.id);
}),
);
const updates = results
.filter((result): result is PromiseFulfilledResult<TenantPhoto> => result.status === 'fulfilled')
.map((result) => result.value);
if (updates.length) {
setPhotos((prev) => prev.map((photo) => updates.find((update) => update.id === photo.id) ?? photo));
toast.success(t('mobilePhotos.bulkUpdated', 'Bulk update applied'));
triggerHaptic('success');
}
} catch {
toast.error(t('mobilePhotos.bulkFailed', 'Bulk update failed'));
} finally {
setBulkBusy(false);
}
}
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
const scope =
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
? scopeOrKey
: scopeOrKey.includes('gallery')
? 'gallery'
: scopeOrKey.includes('guest')
? 'guests'
: 'photos';
const addonKey =
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
? selectAddonKeyForScope(catalogAddons, scope)
: scopeOrKey;
return { scope, addonKey };
}
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
if (!slug) return;
const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey);
setConsentTarget({ scope: scope as any, addonKey });
setConsentOpen(true);
}
async function confirmAddonCheckout(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) {
if (!slug || !consentTarget) return;
const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/photos`)}` : '';
const successUrl = `${currentUrl}?addon_success=1`;
setBusyScope(consentTarget.scope);
setConsentBusy(true);
try {
const checkout = await createEventAddonCheckout(slug, {
addon_key: consentTarget.addonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
accepted_terms: consents.acceptedTerms,
accepted_waiver: consents.acceptedWaiver,
} as any);
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
} else {
toast.error(t('events.errors.checkoutMissing', 'Checkout could not be started.'));
}
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on checkout failed.')));
} finally {
setConsentBusy(false);
setConsentOpen(false);
setConsentTarget(null);
setBusyScope(null);
}
}
return (
<MobileShell
activeTab="uploads"
title={t('mobilePhotos.title', 'Photo moderation')}
onBack={back}
headerActions={
<XStack space="$3">
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={text} />
</HeaderActionButton>
</XStack>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
<CTAButton
label={t('common.retry', 'Retry')}
tone="ghost"
fullWidth={false}
onPress={() => load()}
/>
</MobileCard>
) : null}
{queuedEventCount > 0 ? (
<MobileCard>
<XStack alignItems="center" justifyContent="space-between" space="$2">
<YStack space="$1" flex={1}>
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobilePhotos.queueTitle', 'Änderungen warten auf Sync')}
</Text>
<Text fontSize="$xs" color={muted}>
{online
? t('mobilePhotos.queueOnline', '{{count}} Aktionen bereit zum Synchronisieren.', {
count: queuedEventCount,
})
: t('mobilePhotos.queueOffline', '{{count}} Aktionen gespeichert offline.', {
count: queuedEventCount,
})}
</Text>
</YStack>
<CTAButton
label={online ? t('mobilePhotos.queueSync', 'Sync') : t('mobilePhotos.queueWaiting', 'Offline')}
onPress={() => syncQueuedActions()}
tone="ghost"
fullWidth={false}
disabled={!online}
loading={syncingQueue}
/>
</XStack>
</MobileCard>
) : null}
<XStack space="$2">
<CTAButton
label={selectionMode ? t('common.done', 'Done') : t('common.select', 'Select')}
tone={selectionMode ? 'primary' : 'ghost'}
fullWidth={false}
onPress={() => {
if (selectionMode) {
clearSelection();
} else {
setSelectionMode(true);
}
}}
/>
<CTAButton
label={t('mobilePhotos.filtersTitle', 'Filter')}
tone="ghost"
fullWidth={false}
onPress={() => setShowFilters(true)}
/>
</XStack>
<MobileInput
type="search"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('photos.filters.search', 'Search uploads …')}
compact
style={{ marginBottom: 12 }}
/>
<XStack space="$2" flexWrap="wrap">
{(['all', 'featured', 'pending', 'hidden'] as FilterKey[]).map((key) => (
<Pressable key={key} onPress={() => setFilter(key)} style={{ flex: 1 }}>
<MobileCard
backgroundColor={filter === key ? infoBg : surface}
borderColor={filter === key ? infoBorder : border}
padding="$2.5"
>
<Text fontSize="$sm" fontWeight="700" textAlign="center" color={text}>
{key === 'all'
? t('common.all', 'All')
: key === 'featured'
? t('photos.filters.featured', 'Featured')
: key === 'pending'
? t('photos.filters.pending', 'Pending')
: t('photos.filters.hidden', 'Hidden')}
</Text>
</MobileCard>
</Pressable>
))}
</XStack>
{!loading ? (
<LimitWarnings
limits={limits}
addons={catalogAddons}
onCheckout={startAddonCheckout}
busyScope={busyScope}
translate={translateLimits(t as any)}
textColor={text}
borderColor={border}
/>
) : null}
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<SkeletonCard key={`ph-${idx}`} height={100} />
))}
</YStack>
) : photos.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<ImageIcon size={28} color={muted} />
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobilePhotos.emptyTitle', 'No uploads yet')}
</Text>
<Text fontSize="$xs" color={muted} textAlign="center">
{t('mobilePhotos.emptyBody', 'Share the QR code so guests can start uploading photos.')}
</Text>
{slug ? (
<CTAButton
label={t('mobilePhotos.emptyAction', 'Share QR code')}
tone="ghost"
fullWidth={false}
onPress={() => navigate(adminPath(`/mobile/events/${slug}/qr`))}
/>
) : null}
</MobileCard>
) : (
<YStack space="$3">
<Text fontSize="$sm" color={muted}>
{t('mobilePhotos.count', '{{count}} photos', { count: totalCount })}
</Text>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gap: 8,
}}
>
{photos.map((photo) => {
const isSelected = selectedIds.includes(photo.id);
const swipeDisabled = selectionMode || busyId === photo.id;
return (
<PhotoSwipeCard
key={photo.id}
photo={photo}
disabled={swipeDisabled}
onOpen={() => (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))}
onModerate={(action) => handleModerationAction(action, photo)}
>
<YStack
borderRadius={10}
overflow="hidden"
borderWidth={1}
borderColor={isSelected ? infoBorder : border}
position="relative"
>
<motion.img
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">
{photo.is_featured ? <PillBadge tone="warning">{t('photos.filters.featured', 'Featured')}</PillBadge> : null}
{photo.status === 'pending' ? (
<PillBadge tone="warning">{t('photos.filters.pending', 'Pending')}</PillBadge>
) : null}
{photo.status === 'hidden' ? <PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge> : null}
</XStack>
{selectionMode ? (
<XStack
position="absolute"
top={6}
right={6}
width={24}
height={24}
borderRadius={999}
alignItems="center"
justifyContent="center"
backgroundColor={isSelected ? primary : 'rgba(255,255,255,0.85)'}
borderWidth={1}
borderColor={isSelected ? primary : border}
>
{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>
);
})}
</div>
{hasMore ? (
<CTAButton label={t('common.loadMore', 'Mehr laden')} onPress={() => setPage((prev) => prev + 1)} />
) : null}
</YStack>
)}
{selectionMode ? (
<YStack
position="fixed"
left={12}
right={12}
bottom="calc(env(safe-area-inset-bottom, 0px) + 96px)"
padding="$3"
borderRadius={18}
backgroundColor={surface}
borderWidth={1}
borderColor={border}
shadowColor={backdrop}
shadowOpacity={0.18}
shadowRadius={16}
shadowOffset={{ width: 0, height: 8 }}
zIndex={60}
space="$2"
>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobilePhotos.selectedCount', '{{count}} selected', { count: selectedIds.length })}
</Text>
<Pressable onPress={() => clearSelection()}>
<Text fontSize="$sm" color={primary} fontWeight="700">
{t('common.clear', 'Clear')}
</Text>
</Pressable>
</XStack>
<XStack space="$2" flexWrap="wrap">
{hasPendingSelection ? (
<CTAButton
label={t('photos.actions.approve', 'Approve')}
onPress={() => applyBulkAction('approve')}
fullWidth={false}
disabled={bulkBusy}
loading={bulkBusy}
/>
) : null}
{hasVisibleSelection ? (
<CTAButton
label={t('photos.actions.hide', 'Hide')}
onPress={() => applyBulkAction('hide')}
tone="ghost"
fullWidth={false}
disabled={bulkBusy}
loading={bulkBusy}
/>
) : null}
{hasHiddenSelection ? (
<CTAButton
label={t('photos.actions.show', 'Show')}
onPress={() => applyBulkAction('show')}
tone="ghost"
fullWidth={false}
disabled={bulkBusy}
loading={bulkBusy}
/>
) : null}
{hasUnfeaturedSelection ? (
<CTAButton
label={t('photos.actions.feature', 'Set highlight')}
onPress={() => applyBulkAction('feature')}
tone="ghost"
fullWidth={false}
disabled={bulkBusy}
loading={bulkBusy}
/>
) : null}
{hasFeaturedSelection ? (
<CTAButton
label={t('photos.actions.unfeature', 'Remove highlight')}
onPress={() => applyBulkAction('unfeature')}
tone="ghost"
fullWidth={false}
disabled={bulkBusy}
loading={bulkBusy}
/>
) : null}
</XStack>
</YStack>
) : null}
<AnimatePresence>
{lightbox ? (
<motion.div
className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.div
initial={{ y: 12, scale: 0.98, opacity: 0 }}
animate={{ y: 0, scale: 1, opacity: 1 }}
exit={{ y: 12, scale: 0.98, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
style={{
width: '100%',
maxWidth: 520,
margin: '0 16px',
background: surface,
borderRadius: 20,
overflow: 'hidden',
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
}}
>
<motion.div
drag
dragElastic={0.2}
dragConstraints={{ left: -120, right: 120, top: -120, bottom: 120 }}
dragSnapToOrigin
onDragEnd={handleLightboxDragEnd}
style={{ touchAction: 'none' }}
>
<motion.img
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">
<XStack space="$2" alignItems="center">
<PillBadge tone="muted">{lightbox.uploader_name || t('events.members.roles.guest', 'Guest')}</PillBadge>
<PillBadge tone="muted"> {lightbox.likes_count ?? 0}</PillBadge>
{lightbox.status === 'pending' ? (
<PillBadge tone="warning">{t('photos.filters.pending', 'Pending')}</PillBadge>
) : null}
{lightbox.status === 'hidden' ? (
<PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge>
) : null}
</XStack>
<XStack space="$2" flexWrap="wrap">
{lightbox.status === 'pending' ? (
<CTAButton
label={
busyId === lightbox.id
? t('common.processing', '...')
: t('photos.actions.approve', 'Approve')
}
onPress={() => approvePhoto(lightbox)}
style={{ flex: 1, minWidth: 140 }}
/>
) : null}
<CTAButton
label={
busyId === lightbox.id
? t('common.processing', '...')
: lightbox.is_featured
? t('photos.actions.unfeature', 'Remove highlight')
: t('photos.actions.feature', 'Set highlight')
}
onPress={() => toggleFeature(lightbox)}
style={{ flex: 1, minWidth: 140 }}
/>
<CTAButton
label={
busyId === lightbox.id
? t('common.processing', '...')
: lightbox.status === 'hidden'
? t('photos.actions.show', 'Show')
: t('photos.actions.hide', 'Hide')
}
onPress={() => toggleVisibility(lightbox)}
style={{ flex: 1, minWidth: 140 }}
/>
</XStack>
<CTAButton
label={t('common.close', 'Close')}
tone="ghost"
onPress={() => setLightboxWithUrl(null)}
/>
</YStack>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
<MobileSheet
open={showFilters}
onClose={() => setShowFilters(false)}
title={t('mobilePhotos.filtersTitle', 'Filter')}
footer={
<CTAButton
label={t('mobilePhotos.applyFilters', 'Apply filters')}
onPress={() => {
setPage(1);
setShowFilters(false);
void load();
}}
/>
}
>
<YStack space="$2">
<MobileField label={t('mobilePhotos.uploader', 'Uploader')}>
<MobileInput
type="text"
value={uploaderFilter}
onChange={(e) => setUploaderFilter(e.target.value)}
placeholder={t('mobilePhotos.uploaderPlaceholder', 'Name or email')}
compact
/>
</MobileField>
<XStack space="$2" alignItems="center">
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={onlyFeatured}
onChange={(e) => setOnlyFeatured(e.target.checked)}
/>
<Text fontSize="$sm" color={text}>
{t('mobilePhotos.onlyFeatured', 'Only featured')}
</Text>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={onlyHidden}
onChange={(e) => setOnlyHidden(e.target.checked)}
/>
<Text fontSize="$sm" color={text}>
{t('mobilePhotos.onlyHidden', 'Only hidden')}
</Text>
</label>
</XStack>
<CTAButton
label={t('common.reset', 'Reset')}
tone="ghost"
onPress={() => {
setUploaderFilter('');
setOnlyFeatured(false);
setOnlyHidden(false);
}}
/>
</YStack>
</MobileSheet>
{eventAddons.length ? (
<YStack marginTop="$3" space="$2">
<Text fontSize="$sm" color={text} fontWeight="700">
{t('events.sections.addons.title', 'Add-ons & Upgrades')}
</Text>
<EventAddonList addons={eventAddons} textColor={text} mutedColor={muted} />
</YStack>
) : null}
<LegalConsentSheet
open={consentOpen}
onClose={() => {
if (consentBusy) return;
setConsentOpen(false);
setConsentTarget(null);
}}
onConfirm={confirmAddonCheckout}
busy={consentBusy}
t={t}
/>
</MobileShell>
);
}
type PhotoSwipeCardProps = {
photo: TenantPhoto;
disabled?: boolean;
onOpen: () => void;
onModerate: (action: PhotoModerationAction['action']) => void;
children: React.ReactNode;
};
type SwipeActionConfig = {
label: string;
bg: string;
text: string;
icon: typeof Eye;
};
function PhotoSwipeCard({ photo, disabled = false, onOpen, onModerate, children }: PhotoSwipeCardProps) {
const { t } = useTranslation('management');
const { successBg, successText, dangerBg, dangerText, infoBg, infoText } = useAdminTheme();
const controls = useAnimationControls();
const dragged = React.useRef(false);
const leftAction = resolvePhotoSwipeAction(photo, 'left');
const rightAction = resolvePhotoSwipeAction(photo, 'right');
const canSwipe = !disabled && (leftAction || rightAction);
const resolveActionConfig = (action: SwipeModerationAction): SwipeActionConfig | null => {
if (!action) {
return null;
}
if (action === 'approve') {
return {
label: t('photos.actions.approve', 'Approve'),
bg: successBg,
text: successText,
icon: Check,
};
}
if (action === 'hide') {
return {
label: t('photos.actions.hide', 'Hide'),
bg: dangerBg,
text: dangerText,
icon: EyeOff,
};
}
return {
label: t('photos.actions.show', 'Show'),
bg: infoBg,
text: infoText,
icon: Eye,
};
};
const leftConfig = resolveActionConfig(leftAction);
const rightConfig = resolveActionConfig(rightAction);
const handleDrag = (_event: PointerEvent, info: PanInfo) => {
if (!canSwipe) {
return;
}
dragged.current = Math.abs(info.offset.x) > 6;
};
const handleDragEnd = (_event: PointerEvent, info: PanInfo) => {
if (!canSwipe) {
return;
}
const swipeThreshold = 64;
if (info.offset.x > swipeThreshold && rightAction) {
onModerate(rightAction);
} else if (info.offset.x < -swipeThreshold && leftAction) {
onModerate(leftAction);
}
dragged.current = false;
void controls.start({ x: 0, transition: { type: 'spring', stiffness: 320, damping: 26 } });
};
const handlePress = () => {
if (dragged.current) {
dragged.current = false;
return;
}
onOpen();
};
return (
<div style={{ position: 'relative' }}>
{leftConfig || rightConfig ? (
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal="$2"
borderRadius="$3"
pointerEvents="none"
style={{ position: 'absolute', inset: 0 }}
>
<XStack flex={1} alignItems="center" justifyContent="flex-start">
{rightConfig ? (
<XStack
alignItems="center"
space="$1"
paddingHorizontal="$2"
paddingVertical="$1"
borderRadius={999}
backgroundColor={rightConfig.bg}
>
<rightConfig.icon size={12} color={rightConfig.text} />
<Text fontSize="$xs" fontWeight="700" color={rightConfig.text}>
{rightConfig.label}
</Text>
</XStack>
) : null}
</XStack>
<XStack flex={1} alignItems="center" justifyContent="flex-end">
{leftConfig ? (
<XStack
alignItems="center"
space="$1"
paddingHorizontal="$2"
paddingVertical="$1"
borderRadius={999}
backgroundColor={leftConfig.bg}
>
<leftConfig.icon size={12} color={leftConfig.text} />
<Text fontSize="$xs" fontWeight="700" color={leftConfig.text}>
{leftConfig.label}
</Text>
</XStack>
) : null}
</XStack>
</XStack>
) : null}
<motion.div
drag={canSwipe ? 'x' : false}
dragElastic={0.2}
dragConstraints={{ left: -80, right: 80 }}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
animate={controls}
initial={{ x: 0 }}
style={{ touchAction: 'pan-y', position: 'relative', zIndex: 1 }}
>
<Pressable onPress={handlePress}>{children}</Pressable>
</motion.div>
</div>
);
}
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: any) => {
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 {
const defaults: Record<string, string> = {
photosBlocked: 'Upload limit reached. Buy more photos to continue.',
photosWarning: '{{remaining}} of {{limit}} photos remaining.',
guestsBlocked: 'Guest limit reached.',
guestsWarning: '{{remaining}} of {{limit}} guests remaining.',
galleryExpired: 'Gallery expired. Extend to keep it online.',
galleryWarningHour: 'Gallery expires in {{hours}} hour.',
galleryWarningHours: 'Gallery expires in {{hours}} hours.',
galleryWarningDay: 'Gallery expires in {{days}} day.',
galleryWarningDays: 'Gallery expires in {{days}} days.',
buyMorePhotos: 'Buy more photos',
extendGallery: 'Extend gallery',
buyMoreGuests: 'Add more guests',
};
return (key, options) => t(`limits.${key}`, defaults[key] ?? key, options);
}
export function LimitWarnings({
limits,
addons,
onCheckout,
busyScope,
translate,
textColor,
borderColor,
}: {
limits: EventLimitSummary | null;
addons: EventAddonCatalogItem[];
onCheckout: (scopeOrKey: 'photos' | 'gallery' | string) => void;
busyScope: string | null;
translate: LimitTranslator;
textColor: string;
borderColor: string;
}) {
const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]);
if (!warnings.length) {
return null;
}
return (
<YStack space="$2">
{warnings.map((warning) => (
<MobileCard key={warning.id} borderColor={borderColor} space="$2">
<Text fontSize="$sm" color={textColor} fontWeight="700">
{warning.message}
</Text>
{(warning.scope === 'photos' || warning.scope === 'gallery' || warning.scope === 'guests')
&& resolveAddonOptions(addons, warning.scope).length ? (
<MobileAddonsPicker
scope={warning.scope}
addons={addons}
busy={busyScope === warning.scope}
onCheckout={onCheckout}
translate={translate}
/>
) : (
<CTAButton
label={
warning.scope === 'photos'
? translate('buyMorePhotos')
: warning.scope === 'gallery'
? translate('extendGallery')
: translate('buyMoreGuests')
}
onPress={() => onCheckout(warning.scope)}
loading={busyScope === warning.scope}
/>
)}
</MobileCard>
))}
</YStack>
);
}
function resolveAddonOptions(addons: EventAddonCatalogItem[], scope: 'photos' | 'gallery' | 'guests'): EventAddonCatalogItem[] {
const whitelist = scopeDefaults[scope];
const filtered = addons.filter((addon) => addon.price_id && whitelist.includes(addon.key));
return filtered.length ? filtered : addons.filter((addon) => addon.price_id);
}
function MobileAddonsPicker({
scope,
addons,
busy,
onCheckout,
translate,
}: {
scope: 'photos' | 'gallery' | 'guests';
addons: EventAddonCatalogItem[];
busy: boolean;
onCheckout: (addonKey: string) => void;
translate: LimitTranslator;
}) {
const options = React.useMemo(() => resolveAddonOptions(addons, scope), [addons, scope]);
const [selected, setSelected] = React.useState<string>(() => options[0]?.key ?? selectAddonKeyForScope(addons, scope));
React.useEffect(() => {
if (options[0]?.key) {
setSelected(options[0].key);
}
}, [options]);
if (!options.length) {
return null;
}
return (
<XStack space="$2" alignItems="center">
<MobileSelect
value={selected}
onChange={(event) => setSelected(event.target.value)}
containerStyle={{ flex: 1, minWidth: 0 }}
compact
>
{options.map((addon) => (
<option key={addon.key} value={addon.key}>
{addon.label ?? addon.key}
</option>
))}
</MobileSelect>
<CTAButton
label={
scope === 'gallery'
? translate('extendGallery')
: scope === 'guests'
? translate('buyMoreGuests')
: translate('buyMorePhotos')
}
disabled={!selected || busy}
onPress={() => selected && onCheckout(selected)}
loading={busy}
fullWidth={false}
/>
</XStack>
);
}
function EventAddonList({ addons, textColor, mutedColor }: { addons: EventAddonSummary[]; textColor: string; mutedColor: string }) {
const { border } = useAdminTheme();
return (
<YStack space="$2">
{addons.map((addon) => (
<MobileCard key={addon.id} borderColor={border} space="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color={textColor}>
{addon.label ?? addon.key}
</Text>
<PillBadge tone={addon.status === 'completed' ? 'success' : addon.status === 'pending' ? 'warning' : 'muted'}>
{addon.status}
</PillBadge>
</XStack>
<Text fontSize="$xs" color={mutedColor}>
{addon.purchased_at ? new Date(addon.purchased_at).toLocaleString() : '—'}
</Text>
<XStack space="$2" marginTop="$1" flexWrap="wrap">
{addon.extra_photos ? <PillBadge tone="muted">+{addon.extra_photos} photos</PillBadge> : null}
{addon.extra_guests ? <PillBadge tone="muted">+{addon.extra_guests} guests</PillBadge> : null}
{addon.extra_gallery_days ? <PillBadge tone="muted">+{addon.extra_gallery_days} days</PillBadge> : null}
</XStack>
</MobileCard>
))}
</YStack>
);
}