375 lines
14 KiB
TypeScript
375 lines
14 KiB
TypeScript
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';
|
||
import { useTheme } from '@tamagui/core';
|
||
|
||
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);
|
||
const theme = useTheme();
|
||
const text = String(theme.color?.val ?? '#111827');
|
||
const muted = String(theme.gray?.val ?? '#4b5563');
|
||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||
const infoBg = String(theme.blue3?.val ?? '#e8f1ff');
|
||
const infoBorder = String(theme.blue6?.val ?? '#bfdbfe');
|
||
const danger = String(theme.red10?.val ?? '#b91c1c');
|
||
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 (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('mobilePhotos.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)));
|
||
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.'));
|
||
}
|
||
} 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)));
|
||
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);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<MobileShell
|
||
activeTab="uploads"
|
||
title={t('mobilePhotos.title', 'Photo moderation')}
|
||
onBack={() => navigate(-1)}
|
||
headerActions={
|
||
<XStack space="$3">
|
||
<Pressable onPress={() => setShowFilters(true)}>
|
||
<Filter size={18} color={text} />
|
||
</Pressable>
|
||
<Pressable onPress={() => load()}>
|
||
<RefreshCcw size={18} color={text} />
|
||
</Pressable>
|
||
</XStack>
|
||
}
|
||
>
|
||
{error ? (
|
||
<MobileCard>
|
||
<Text fontWeight="700" color={danger}>
|
||
{error}
|
||
</Text>
|
||
</MobileCard>
|
||
) : null}
|
||
|
||
<input
|
||
type="search"
|
||
value={search}
|
||
onChange={(e) => {
|
||
setSearch(e.target.value);
|
||
setPage(1);
|
||
}}
|
||
placeholder={t('photos.filters.search', 'Search uploads …')}
|
||
style={{ ...baseInputStyle, 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 ? 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')
|
||
: t('photos.filters.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={muted} />
|
||
<Text fontSize="$sm" color={muted}>
|
||
{t('mobilePhotos.empty', 'No photos found.')}
|
||
</Text>
|
||
</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) => (
|
||
<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 === 'hidden' ? <PillBadge tone="muted">{t('photos.filters.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: surface,
|
||
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: 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>
|
||
</XStack>
|
||
<XStack space="$2" flexWrap="wrap">
|
||
<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}
|
||
|
||
<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">
|
||
<Field label={t('mobilePhotos.uploader', 'Uploader')} color={text}>
|
||
<input
|
||
type="text"
|
||
value={uploaderFilter}
|
||
onChange={(e) => setUploaderFilter(e.target.value)}
|
||
placeholder={t('mobilePhotos.uploaderPlaceholder', 'Name or email')}
|
||
style={baseInputStyle}
|
||
/>
|
||
</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={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>
|
||
</MobileShell>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|