Completed the full mobile app polish pass: navigation feel, safe‑area consistency, input styling, list rows, FAB

patterns, skeleton loading, photo selection/bulk actions with shared‑element transitions, notification detail sheet,
  offline banner, maskable manifest icons, and route prefetching.

  Key changes

  - Navigation/shell: press feedback on all header actions, glassy sticky header and tab bar, safer bottom spacing
    (resources/js/admin/mobile/components/MobileShell.tsx, resources/js/admin/mobile/components/BottomNav.tsx).
  - Forms + lists: shared mobile form controls, list‑style rows in settings/profile, consistent inputs across core
    flows (resources/js/admin/mobile/components/FormControls.tsx, resources/js/admin/mobile/SettingsPage.tsx,
    resources/js/admin/mobile/ProfilePage.tsx, resources/js/admin/mobile/EventFormPage.tsx, resources/js/admin/mobile/
    EventMembersPage.tsx, resources/js/admin/mobile/EventTasksPage.tsx, resources/js/admin/mobile/
    EventGuestNotificationsPage.tsx, resources/js/admin/mobile/NotificationsPage.tsx, resources/js/admin/mobile/
    EventPhotosPage.tsx, resources/js/admin/mobile/EventsPage.tsx).
  - Media workflows: shared‑element photo transitions, selection mode + bulk actions bar (resources/js/admin/mobile/
    EventPhotosPage.tsx).
  - Loading UX: shimmering skeletons (resources/css/app.css, resources/js/admin/mobile/components/Primitives.tsx).
  - PWA polish + perf: maskable icons, offline banner hook, and route prefetch (public/manifest.json, resources/js/
    admin/mobile/hooks/useOnlineStatus.tsx, resources/js/admin/mobile/prefetch.ts, resources/js/admin/main.tsx).
This commit is contained in:
Codex Agent
2025-12-27 23:55:48 +01:00
parent a8b54b75ea
commit 4ce409e918
36 changed files with 1288 additions and 579 deletions

View File

@@ -1,12 +1,14 @@
import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Image as ImageIcon, RefreshCcw, Search, Filter } from 'lucide-react';
import { Image as ImageIcon, RefreshCcw, Filter, Check } 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 { MobileShell } from './components/MobileShell';
import { MobileCard, PillBadge, CTAButton } from './components/Primitives';
import { AnimatePresence, motion } 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,
updatePhotoVisibility,
@@ -56,6 +58,9 @@ export default function MobileEventPhotosPage() {
const [onlyFeatured, setOnlyFeatured] = React.useState(false);
const [onlyHidden, setOnlyHidden] = React.useState(false);
const [lightbox, setLightbox] = React.useState<TenantPhoto | null>(null);
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[]>([]);
@@ -73,19 +78,13 @@ export default function MobileEventPhotosPage() {
const surface = String(theme.surface?.val ?? '#ffffff');
const backdrop = String(theme.gray12?.val ?? '#0f172a');
const baseInputStyle = React.useMemo<React.CSSProperties>(
() => ({
width: '100%',
height: 38,
borderRadius: 10,
border: `1px solid ${border}`,
padding: '0 12px',
fontSize: 13,
background: surface,
color: text,
}),
[border, surface, text],
);
React.useEffect(() => {
if (lightbox) {
setSelectionMode(false);
setSelectedIds([]);
}
}, [lightbox]);
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam);
@@ -213,6 +212,75 @@ export default function MobileEventPhotosPage() {
}
}
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);
function toggleSelection(id: number) {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]));
}
function clearSelection() {
setSelectedIds([]);
setSelectionMode(false);
}
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;
}
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));
setLightbox((prev) => (prev ? updates.find((update) => update.id === prev.id) ?? prev : prev));
toast.success(t('mobilePhotos.bulkUpdated', 'Bulk update applied'));
}
} 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'
@@ -276,12 +344,26 @@ export default function MobileEventPhotosPage() {
onBack={() => navigate(-1)}
headerActions={
<XStack space="$3">
<Pressable onPress={() => setShowFilters(true)}>
<HeaderActionButton
onPress={() => {
if (selectionMode) {
clearSelection();
} else {
setSelectionMode(true);
}
}}
ariaLabel={selectionMode ? t('common.done', 'Done') : t('common.select', 'Select')}
>
<Text fontSize="$xs" fontWeight="800" color={text}>
{selectionMode ? t('common.done', 'Done') : t('common.select', 'Select')}
</Text>
</HeaderActionButton>
<HeaderActionButton onPress={() => setShowFilters(true)} ariaLabel={t('mobilePhotos.filtersTitle', 'Filter')}>
<Filter size={18} color={text} />
</Pressable>
<Pressable onPress={() => load()}>
</HeaderActionButton>
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={text} />
</Pressable>
</HeaderActionButton>
</XStack>
}
>
@@ -293,15 +375,16 @@ export default function MobileEventPhotosPage() {
</MobileCard>
) : null}
<input
<MobileInput
type="search"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('photos.filters.search', 'Search uploads …')}
style={{ ...baseInputStyle, marginBottom: 12 }}
compact
style={{ marginBottom: 12 }}
/>
<XStack space="$2" flexWrap="wrap">
@@ -341,7 +424,7 @@ export default function MobileEventPhotosPage() {
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`ph-${idx}`} height={100} opacity={0.6} />
<SkeletonCard key={`ph-${idx}`} height={100} />
))}
</YStack>
) : photos.length === 0 ? (
@@ -363,24 +446,53 @@ export default function MobileEventPhotosPage() {
gap: 8,
}}
>
{photos.map((photo) => (
<Pressable key={photo.id} onPress={() => setLightbox(photo)}>
<YStack borderRadius={10} overflow="hidden" borderWidth={1} borderColor={border}>
<img
src={photo.thumbnail_url ?? photo.url ?? undefined}
alt={photo.caption ?? 'Photo'}
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>
{photos.map((photo) => {
const isSelected = selectedIds.includes(photo.id);
return (
<Pressable
key={photo.id}
onPress={() => (selectionMode ? toggleSelection(photo.id) : setLightbox(photo))}
>
<YStack
borderRadius={10}
overflow="hidden"
borderWidth={1}
borderColor={isSelected ? infoBorder : border}
>
<motion.img
layoutId={`photo-${photo.id}`}
src={photo.thumbnail_url ?? photo.url ?? undefined}
alt={photo.caption ?? 'Photo'}
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 ? String(theme.primary?.val ?? '#007AFF') : 'rgba(255,255,255,0.85)'}
borderWidth={1}
borderColor={isSelected ? String(theme.primary?.val ?? '#007AFF') : border}
>
{isSelected ? <Check size={14} color="white" /> : null}
</XStack>
) : null}
{photo.status === 'hidden' ? <PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge> : null}
</XStack>
</YStack>
</Pressable>
))}
</YStack>
</Pressable>
);
})}
</div>
{hasMore ? (
<CTAButton label={t('common.loadMore', 'Mehr laden')} onPress={() => setPage((prev) => prev + 1)} />
@@ -388,75 +500,169 @@ export default function MobileEventPhotosPage() {
</YStack>
)}
{lightbox ? (
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center">
<div
style={{
width: '100%',
maxWidth: 520,
margin: '0 16px',
background: surface,
borderRadius: 20,
overflow: 'hidden',
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
}}
{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="#0f172a"
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={String(theme.primary?.val ?? '#007AFF')} 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 }}
>
<img
src={lightbox.url ?? lightbox.thumbnail_url ?? undefined}
alt={lightbox.caption ?? 'Photo'}
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: backdrop }}
/>
<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' ? (
<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.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 }}
/>
<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', '...')
: t('photos.actions.approve', 'Approve')
: lightbox.is_featured
? t('photos.actions.unfeature', 'Remove highlight')
: t('photos.actions.feature', 'Set highlight')
}
onPress={() => approvePhoto(lightbox)}
onPress={() => toggleFeature(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={() => setLightbox(null)} />
</YStack>
</div>
</div>
) : null}
<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={() => setLightbox(null)} />
</YStack>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
<MobileSheet
open={showFilters}
@@ -474,15 +680,15 @@ export default function MobileEventPhotosPage() {
}
>
<YStack space="$2">
<Field label={t('mobilePhotos.uploader', 'Uploader')} color={text}>
<input
<MobileField label={t('mobilePhotos.uploader', 'Uploader')}>
<MobileInput
type="text"
value={uploaderFilter}
onChange={(e) => setUploaderFilter(e.target.value)}
placeholder={t('mobilePhotos.uploaderPlaceholder', 'Name or email')}
style={baseInputStyle}
compact
/>
</Field>
</MobileField>
<XStack space="$2" alignItems="center">
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
@@ -541,17 +747,6 @@ export default function MobileEventPhotosPage() {
);
}
function Field({ label, color, children }: { label: string; color: string; children: React.ReactNode }) {
return (
<YStack space="$1">
<Text fontSize="$sm" color={color}>
{label}
</Text>
{children}
</YStack>
);
}
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {
@@ -659,25 +854,13 @@ function MobileAddonsPicker({
return (
<XStack space="$2" alignItems="center">
<select
value={selected}
onChange={(event) => setSelected(event.target.value)}
style={{
flex: 1,
height: 40,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 13,
background: '#fff',
}}
>
<MobileSelect value={selected} onChange={(event) => setSelected(event.target.value)} style={{ flex: 1 }} compact>
{options.map((addon) => (
<option key={addon.key} value={addon.key}>
{addon.label ?? addon.key}
</option>
))}
</select>
</MobileSelect>
<CTAButton
label={
scope === 'gallery'