Added Phase‑1 continuation work across deep links, offline moderation queue, and admin push.
resources/js/admin/mobile/lib.
- Admin push is end‑to‑end: new backend model/migration/service/job + API endpoints, admin runtime config, push‑aware
service worker, and a settings toggle via useAdminPushSubscription. Notifications now auto‑refresh on push.
- New PHP/JS tests: admin push API feature test and queue/haptics unit tests
Added admin-specific PWA icon assets and wired them into the admin manifest, service worker, and admin shell, plus a
new “Device & permissions” card in mobile Settings with a persistent storage action and translations.
Details: public/manifest.json, public/admin-sw.js, resources/views/admin.blade.php, new icons in public/; new hook
resources/js/admin/mobile/hooks/useDevicePermissions.ts, helpers/tests in resources/js/admin/mobile/lib/
devicePermissions.ts + resources/js/admin/mobile/lib/devicePermissions.test.ts, and Settings UI updates in resources/
js/admin/mobile/SettingsPage.tsx with copy in resources/js/admin/i18n/locales/en/management.json and resources/js/
admin/i18n/locales/de/management.json.
This commit is contained in:
@@ -11,6 +11,7 @@ import { MobileCard, PillBadge, CTAButton, SkeletonCard } from './components/Pri
|
||||
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
|
||||
import {
|
||||
getEventPhotos,
|
||||
getEventPhoto,
|
||||
updatePhotoVisibility,
|
||||
featurePhoto,
|
||||
unfeaturePhoto,
|
||||
@@ -33,11 +34,21 @@ 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 {
|
||||
enqueuePhotoAction,
|
||||
loadPhotoQueue,
|
||||
removePhotoAction,
|
||||
replacePhotoQueue,
|
||||
type PhotoModerationAction,
|
||||
} from './lib/photoModerationQueue';
|
||||
import { ADMIN_EVENT_PHOTOS_PATH } from '../constants';
|
||||
|
||||
type FilterKey = 'all' | 'featured' | 'hidden' | 'pending';
|
||||
|
||||
export default function MobileEventPhotosPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const { slug: slugParam, photoId: photoIdParam } = useParams<{ slug?: string; photoId?: string }>();
|
||||
const { activeEvent, selectEvent } = useEventContext();
|
||||
const slug = slugParam ?? activeEvent?.slug ?? null;
|
||||
const navigate = useNavigate();
|
||||
@@ -57,7 +68,9 @@ export default function MobileEventPhotosPage() {
|
||||
const [uploaderFilter, setUploaderFilter] = React.useState('');
|
||||
const [onlyFeatured, setOnlyFeatured] = React.useState(false);
|
||||
const [onlyHidden, setOnlyHidden] = React.useState(false);
|
||||
const [lightbox, setLightbox] = React.useState<TenantPhoto | null>(null);
|
||||
const [lightboxId, setLightboxId] = React.useState<number | 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);
|
||||
@@ -68,6 +81,9 @@ export default function MobileEventPhotosPage() {
|
||||
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 theme = useTheme();
|
||||
const text = String(theme.color?.val ?? '#111827');
|
||||
const muted = String(theme.gray?.val ?? '#4b5563');
|
||||
@@ -78,12 +94,47 @@ export default function MobileEventPhotosPage() {
|
||||
const surface = String(theme.surface?.val ?? '#ffffff');
|
||||
const backdrop = String(theme.gray12?.val ?? '#0f172a');
|
||||
|
||||
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 basePhotosPath = slug ? ADMIN_EVENT_PHOTOS_PATH(slug) : adminPath('/mobile/events');
|
||||
const parsedPhotoId = React.useMemo(() => {
|
||||
if (!photoIdParam) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(photoIdParam);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}, [photoIdParam]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (lightbox) {
|
||||
if (lightboxId !== null && lightboxIndex === -1 && !loading && pendingPhotoId !== lightboxId) {
|
||||
setLightboxId(null);
|
||||
}
|
||||
}, [lightboxId, lightboxIndex, loading, pendingPhotoId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (lightboxId !== null) {
|
||||
setSelectionMode(false);
|
||||
setSelectedIds([]);
|
||||
}
|
||||
}, [lightbox]);
|
||||
}, [lightboxId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (parsedPhotoId === null) {
|
||||
if (!photoIdParam) {
|
||||
setLightboxId(null);
|
||||
}
|
||||
setPendingPhotoId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLightboxId(parsedPhotoId);
|
||||
setPendingPhotoId(parsedPhotoId);
|
||||
}, [parsedPhotoId, photoIdParam]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (slugParam && activeEvent?.slug !== slugParam) {
|
||||
@@ -150,66 +201,255 @@ export default function MobileEventPhotosPage() {
|
||||
setPage(1);
|
||||
}, [filter, slug]);
|
||||
|
||||
async function toggleVisibility(photo: TenantPhoto) {
|
||||
if (!slug) return;
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = await updatePhotoVisibility(slug, photo.id, photo.status === 'hidden');
|
||||
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
|
||||
setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev));
|
||||
toast.success(
|
||||
updated.status === 'hidden'
|
||||
? t('mobilePhotos.hideSuccess', 'Photo hidden')
|
||||
: t('mobilePhotos.showSuccess', 'Photo shown'),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.')));
|
||||
toast.error(t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.'));
|
||||
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;
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
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, 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 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, { replace: true });
|
||||
}
|
||||
} 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) {
|
||||
if (!slug) return;
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = photo.is_featured ? await unfeaturePhoto(slug, photo.id) : await featurePhoto(slug, photo.id);
|
||||
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
|
||||
setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev));
|
||||
toast.success(
|
||||
updated.is_featured
|
||||
? t('mobilePhotos.featureSuccess', 'Als Highlight markiert')
|
||||
: t('mobilePhotos.unfeatureSuccess', 'Highlight entfernt'),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.')));
|
||||
toast.error(t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.'));
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
const action = photo.is_featured ? 'unfeature' : 'feature';
|
||||
await handleModerationAction(action, photo);
|
||||
}
|
||||
|
||||
async function approvePhoto(photo: TenantPhoto) {
|
||||
if (!slug) return;
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = await updatePhotoStatus(slug, photo.id, 'approved');
|
||||
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
|
||||
setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev));
|
||||
toast.success(t('mobilePhotos.approveSuccess', 'Photo approved'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.')));
|
||||
toast.error(t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.'));
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
await handleModerationAction('approve', photo);
|
||||
}
|
||||
|
||||
const selectedPhotos = React.useMemo(
|
||||
@@ -221,6 +461,12 @@ export default function MobileEventPhotosPage() {
|
||||
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]));
|
||||
@@ -231,6 +477,32 @@ export default function MobileEventPhotosPage() {
|
||||
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, { replace: true });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
[lightboxIndex, photos, setLightboxWithUrl],
|
||||
);
|
||||
|
||||
async function applyBulkAction(action: 'approve' | 'hide' | 'show' | 'feature' | 'unfeature') {
|
||||
if (!slug || bulkBusy || selectedPhotos.length === 0) return;
|
||||
setBulkBusy(true);
|
||||
@@ -246,6 +518,19 @@ export default function MobileEventPhotosPage() {
|
||||
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) => {
|
||||
@@ -271,8 +556,8 @@ export default function MobileEventPhotosPage() {
|
||||
|
||||
if (updates.length) {
|
||||
setPhotos((prev) => prev.map((photo) => updates.find((update) => update.id === photo.id) ?? photo));
|
||||
setLightbox((prev) => (prev ? updates.find((update) => update.id === prev.id) ?? prev : prev));
|
||||
toast.success(t('mobilePhotos.bulkUpdated', 'Bulk update applied'));
|
||||
triggerHaptic('success');
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('mobilePhotos.bulkFailed', 'Bulk update failed'));
|
||||
@@ -375,6 +660,35 @@ export default function MobileEventPhotosPage() {
|
||||
</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}
|
||||
|
||||
<MobileInput
|
||||
type="search"
|
||||
value={search}
|
||||
@@ -451,7 +765,7 @@ export default function MobileEventPhotosPage() {
|
||||
return (
|
||||
<Pressable
|
||||
key={photo.id}
|
||||
onPress={() => (selectionMode ? toggleSelection(photo.id) : setLightbox(photo))}
|
||||
onPress={() => (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))}
|
||||
>
|
||||
<YStack
|
||||
borderRadius={10}
|
||||
@@ -605,12 +919,21 @@ export default function MobileEventPhotosPage() {
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
|
||||
}}
|
||||
>
|
||||
<motion.img
|
||||
layoutId={`photo-${lightbox.id}`}
|
||||
src={lightbox.url ?? lightbox.thumbnail_url ?? undefined}
|
||||
alt={lightbox.caption ?? 'Photo'}
|
||||
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: backdrop }}
|
||||
/>
|
||||
<motion.div
|
||||
drag
|
||||
dragElastic={0.2}
|
||||
dragConstraints={{ left: -120, right: 120, top: -120, bottom: 120 }}
|
||||
dragSnapToOrigin
|
||||
onDragEnd={handleLightboxDragEnd}
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
<motion.img
|
||||
layoutId={`photo-${lightbox.id}`}
|
||||
src={lightbox.url ?? lightbox.thumbnail_url ?? undefined}
|
||||
alt={lightbox.caption ?? 'Photo'}
|
||||
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: backdrop }}
|
||||
/>
|
||||
</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>
|
||||
@@ -657,7 +980,11 @@ export default function MobileEventPhotosPage() {
|
||||
style={{ flex: 1, minWidth: 140 }}
|
||||
/>
|
||||
</XStack>
|
||||
<CTAButton label={t('common.close', 'Close')} tone="ghost" onPress={() => setLightbox(null)} />
|
||||
<CTAButton
|
||||
label={t('common.close', 'Close')}
|
||||
tone="ghost"
|
||||
onPress={() => setLightboxWithUrl(null, { replace: true })}
|
||||
/>
|
||||
</YStack>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
Reference in New Issue
Block a user