first implementation of tamagui mobile pages

This commit is contained in:
Codex Agent
2025-12-10 15:49:08 +01:00
parent 5c93bfa405
commit 9930b272ca
39 changed files with 491904 additions and 2727 deletions

View File

@@ -0,0 +1,339 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Image as ImageIcon, RefreshCcw, Search, Filter } 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 { MobileScaffold } from './components/Scaffold';
import { MobileCard, PillBadge, CTAButton } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { getEventPhotos, updatePhotoVisibility, featurePhoto, unfeaturePhoto, TenantPhoto } from '../api';
import toast from 'react-hot-toast';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { useMobileNav } from './hooks/useMobileNav';
import { MobileSheet } from './components/Sheet';
type FilterKey = 'all' | 'featured' | 'hidden';
export default function MobileEventPhotosPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
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 { go } = useMobileNav(slug);
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 [lightbox, setLightbox] = React.useState<TenantPhoto | null>(null);
const load = React.useCallback(async () => {
if (!slug) return;
setLoading(true);
setError(null);
try {
const result = await getEventPhotos(slug, {
page,
perPage: 20,
sort: 'desc',
featured: filter === 'featured' || onlyFeatured,
status: filter === 'hidden' || onlyHidden ? 'hidden' : undefined,
search: search || undefined,
uploader: uploaderFilter || undefined,
});
setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos]));
setTotalCount(result.meta?.total ?? result.photos.length);
const lastPage = result.meta?.last_page ?? 1;
setHasMore(page < lastPage);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Fotos konnten nicht geladen werden.')));
}
} finally {
setLoading(false);
}
}, [slug, filter, t, page]);
React.useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
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)));
toast.success(
updated.status === 'hidden'
? t('events.photos.hideSuccess', 'Foto versteckt')
: t('events.photos.showSuccess', 'Foto eingeblendet'),
);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Sichtbarkeit konnte nicht geändert werden.')));
toast.error(t('events.photos.hideFailed', 'Sichtbarkeit konnte nicht geändert werden.'));
}
} finally {
setBusyId(null);
}
}
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)));
toast.success(
updated.is_featured
? t('events.photos.featureSuccess', 'Als Highlight markiert')
: t('events.photos.unfeatureSuccess', 'Highlight entfernt'),
);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Feature konnte nicht geändert werden.')));
toast.error(t('events.photos.featureFailed', 'Feature konnte nicht geändert werden.'));
}
} finally {
setBusyId(null);
}
}
return (
<MobileScaffold
title={t('events.photos.title', 'Photo Moderation')}
onBack={() => navigate(-1)}
rightSlot={
<XStack space="$3">
<Pressable onPress={() => setShowFilters(true)}>
<Filter size={18} color="#0f172a" />
</Pressable>
<Pressable onPress={() => load()}>
<RefreshCcw size={18} color="#0f172a" />
</Pressable>
</XStack>
}
footer={
<BottomNav active="events" onNavigate={go} />
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</MobileCard>
) : null}
<input
type="search"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('events.photos.search', 'Search photos')}
style={{
width: '100%',
height: 38,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 13,
background: 'white',
marginBottom: 12,
}}
/>
<XStack space="$2">
{(['all', 'featured', 'hidden'] as FilterKey[]).map((key) => (
<Pressable key={key} onPress={() => setFilter(key)} style={{ flex: 1 }}>
<MobileCard
backgroundColor={filter === key ? '#e8f1ff' : 'white'}
borderColor={filter === key ? '#bfdbfe' : '#e5e7eb'}
padding="$2.5"
>
<Text fontSize="$sm" fontWeight="700" textAlign="center" color="#111827">
{key === 'all' ? t('common.all', 'All') : key === 'featured' ? t('events.photos.featured', 'Featured') : t('events.photos.hidden', 'Hidden')}
</Text>
</MobileCard>
</Pressable>
))}
</XStack>
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`ph-${idx}`} height={100} opacity={0.6} />
))}
</YStack>
) : photos.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<ImageIcon size={28} color="#9ca3af" />
<Text fontSize="$sm" color="#4b5563">
{t('events.photos.empty', 'Keine Fotos gefunden.')}
</Text>
</MobileCard>
) : (
<YStack space="$3">
<Text fontSize="$sm" color="#4b5563">
{t('events.photos.count', '{{count}} Fotos', { count: totalCount })}
</Text>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gap: 8,
}}
>
{photos.map((photo) => (
<Pressable key={photo.id} onPress={() => setLightbox(photo)}>
<YStack borderRadius={10} overflow="hidden" borderWidth={1} borderColor="#e5e7eb">
<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('events.photos.featured', 'Featured')}</PillBadge> : null}
{photo.status === 'hidden' ? <PillBadge tone="muted">{t('events.photos.hidden', 'Hidden')}</PillBadge> : null}
</XStack>
</YStack>
</Pressable>
))}
</div>
{hasMore ? (
<CTAButton label={t('common.loadMore', 'Mehr laden')} onPress={() => setPage((prev) => prev + 1)} />
) : null}
</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: '#fff',
borderRadius: 20,
overflow: 'hidden',
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
}}
>
<img
src={lightbox.url ?? lightbox.thumbnail_url ?? undefined}
alt={lightbox.caption ?? 'Photo'}
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: '#0f172a' }}
/>
<YStack padding="$3" space="$2">
<XStack space="$2" alignItems="center">
<PillBadge tone="muted">{lightbox.uploader_name || t('events.photos.guest', 'Gast')}</PillBadge>
<PillBadge tone="muted"> {lightbox.likes_count ?? 0}</PillBadge>
</XStack>
<XStack space="$2">
<CTAButton
label={
busyId === lightbox.id
? t('common.processing', '...')
: lightbox.is_featured
? t('events.photos.unfeature', 'Unfeature')
: t('events.photos.feature', 'Feature')
}
onPress={() => toggleFeature(lightbox)}
/>
<CTAButton
label={
busyId === lightbox.id
? t('common.processing', '...')
: lightbox.status === 'hidden'
? t('events.photos.show', 'Show')
: t('events.photos.hide', 'Hide')
}
onPress={() => toggleVisibility(lightbox)}
/>
</XStack>
<CTAButton label={t('common.close', 'Close')} onPress={() => setLightbox(null)} />
</YStack>
</div>
</div>
) : null}
<MobileSheet
open={showFilters}
onClose={() => setShowFilters(false)}
title={t('events.photos.filters', 'Filter')}
footer={
<CTAButton
label={t('events.photos.applyFilters', 'Apply filters')}
onPress={() => {
setPage(1);
setShowFilters(false);
void load();
}}
/>
}
>
<YStack space="$2">
<Field label={t('events.photos.uploader', 'Uploader')}>
<input
type="text"
value={uploaderFilter}
onChange={(e) => setUploaderFilter(e.target.value)}
placeholder={t('events.photos.uploaderPlaceholder', 'Name or email')}
style={inputStyle}
/>
</Field>
<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="#111827">
{t('events.photos.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="#111827">
{t('events.photos.onlyHidden', 'Only hidden')}
</Text>
</label>
</XStack>
<CTAButton
label={t('common.reset', 'Reset')}
tone="ghost"
onPress={() => {
setUploaderFilter('');
setOnlyFeatured(false);
setOnlyHidden(false);
}}
/>
</YStack>
</MobileSheet>
</MobileScaffold>
);
}