Files
fotospiel-app/resources/js/admin/mobile/EventPhotosPage.tsx

362 lines
13 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.
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 { MobileShell } from './components/MobileShell';
import { MobileCard, PillBadge, CTAButton } from './components/Primitives';
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 { MobileSheet } from './components/Sheet';
import { useEventContext } from '../context/EventContext';
type FilterKey = 'all' | 'featured' | 'hidden';
export default function MobileEventPhotosPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const { activeEvent, selectEvent } = useEventContext();
const slug = slugParam ?? activeEvent?.slug ?? 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 [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);
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 result = await getEventPhotos(slug, {
page,
perPage: 20,
sort: 'desc',
featured: filter === 'featured' || onlyFeatured,
status: filter === 'hidden' || onlyHidden ? 'hidden' : undefined,
search: search || 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 (
<MobileShell
activeTab="uploads"
title={t('events.photos.title', 'Photo Moderation')}
onBack={() => navigate(-1)}
headerActions={
<XStack space="$3">
<Pressable onPress={() => setShowFilters(true)}>
<Filter size={18} color="#0f172a" />
</Pressable>
<Pressable onPress={() => load()}>
<RefreshCcw size={18} color="#0f172a" />
</Pressable>
</XStack>
}
>
{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>
</MobileShell>
);
}
const inputStyle: React.CSSProperties = {
width: '100%',
height: 38,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 13,
background: 'white',
};
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<YStack space="$1">
<Text fontSize="$sm" color="#111827">
{label}
</Text>
{children}
</YStack>
);
}