weitere perfektionierung der neuen mobile app
This commit is contained in:
@@ -13,6 +13,7 @@ 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';
|
||||
|
||||
@@ -37,6 +38,29 @@ 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 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);
|
||||
@@ -62,7 +86,7 @@ export default function MobileEventPhotosPage() {
|
||||
setHasMore(page < lastPage);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Fotos konnten nicht geladen werden.')));
|
||||
setError(getApiErrorMessage(err, t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.')));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -83,15 +107,16 @@ export default function MobileEventPhotosPage() {
|
||||
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('events.photos.hideSuccess', 'Foto versteckt')
|
||||
: t('events.photos.showSuccess', 'Foto eingeblendet'),
|
||||
? t('mobilePhotos.hideSuccess', 'Photo hidden')
|
||||
: t('mobilePhotos.showSuccess', 'Photo shown'),
|
||||
);
|
||||
} 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.'));
|
||||
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);
|
||||
@@ -104,15 +129,16 @@ export default function MobileEventPhotosPage() {
|
||||
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('events.photos.featureSuccess', 'Als Highlight markiert')
|
||||
: t('events.photos.unfeatureSuccess', 'Highlight entfernt'),
|
||||
? t('mobilePhotos.featureSuccess', 'Als Highlight markiert')
|
||||
: t('mobilePhotos.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.'));
|
||||
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);
|
||||
@@ -122,22 +148,22 @@ export default function MobileEventPhotosPage() {
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="uploads"
|
||||
title={t('events.photos.title', 'Photo Moderation')}
|
||||
title={t('mobilePhotos.title', 'Photo moderation')}
|
||||
onBack={() => navigate(-1)}
|
||||
headerActions={
|
||||
<XStack space="$3">
|
||||
<Pressable onPress={() => setShowFilters(true)}>
|
||||
<Filter size={18} color="#0f172a" />
|
||||
<Filter size={18} color={text} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => load()}>
|
||||
<RefreshCcw size={18} color="#0f172a" />
|
||||
<RefreshCcw size={18} color={text} />
|
||||
</Pressable>
|
||||
</XStack>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
@@ -146,33 +172,28 @@ export default function MobileEventPhotosPage() {
|
||||
<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,
|
||||
}}
|
||||
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 ? '#e8f1ff' : 'white'}
|
||||
borderColor={filter === key ? '#bfdbfe' : '#e5e7eb'}
|
||||
backgroundColor={filter === key ? infoBg : surface}
|
||||
borderColor={filter === key ? infoBorder : border}
|
||||
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 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>
|
||||
@@ -187,15 +208,15 @@ export default function MobileEventPhotosPage() {
|
||||
</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.')}
|
||||
<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="#4b5563">
|
||||
{t('events.photos.count', '{{count}} Fotos', { count: totalCount })}
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('mobilePhotos.count', '{{count}} photos', { count: totalCount })}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
@@ -206,15 +227,15 @@ export default function MobileEventPhotosPage() {
|
||||
>
|
||||
{photos.map((photo) => (
|
||||
<Pressable key={photo.id} onPress={() => setLightbox(photo)}>
|
||||
<YStack borderRadius={10} overflow="hidden" borderWidth={1} borderColor="#e5e7eb">
|
||||
<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('events.photos.featured', 'Featured')}</PillBadge> : null}
|
||||
{photo.status === 'hidden' ? <PillBadge tone="muted">{t('events.photos.hidden', 'Hidden')}</PillBadge> : null}
|
||||
{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>
|
||||
@@ -233,7 +254,7 @@ export default function MobileEventPhotosPage() {
|
||||
width: '100%',
|
||||
maxWidth: 520,
|
||||
margin: '0 16px',
|
||||
background: '#fff',
|
||||
background: surface,
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
|
||||
@@ -242,36 +263,38 @@ export default function MobileEventPhotosPage() {
|
||||
<img
|
||||
src={lightbox.url ?? lightbox.thumbnail_url ?? undefined}
|
||||
alt={lightbox.caption ?? 'Photo'}
|
||||
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: '#0f172a' }}
|
||||
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.photos.guest', 'Gast')}</PillBadge>
|
||||
<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">
|
||||
<XStack space="$2" flexWrap="wrap">
|
||||
<CTAButton
|
||||
label={
|
||||
busyId === lightbox.id
|
||||
? t('common.processing', '...')
|
||||
: lightbox.is_featured
|
||||
? t('events.photos.unfeature', 'Unfeature')
|
||||
: t('events.photos.feature', 'Feature')
|
||||
? 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('events.photos.show', 'Show')
|
||||
: t('events.photos.hide', 'Hide')
|
||||
? 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')} onPress={() => setLightbox(null)} />
|
||||
<CTAButton label={t('common.close', 'Close')} tone="ghost" onPress={() => setLightbox(null)} />
|
||||
</YStack>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,10 +303,10 @@ export default function MobileEventPhotosPage() {
|
||||
<MobileSheet
|
||||
open={showFilters}
|
||||
onClose={() => setShowFilters(false)}
|
||||
title={t('events.photos.filters', 'Filter')}
|
||||
title={t('mobilePhotos.filtersTitle', 'Filter')}
|
||||
footer={
|
||||
<CTAButton
|
||||
label={t('events.photos.applyFilters', 'Apply filters')}
|
||||
label={t('mobilePhotos.applyFilters', 'Apply filters')}
|
||||
onPress={() => {
|
||||
setPage(1);
|
||||
setShowFilters(false);
|
||||
@@ -293,13 +316,13 @@ export default function MobileEventPhotosPage() {
|
||||
}
|
||||
>
|
||||
<YStack space="$2">
|
||||
<Field label={t('events.photos.uploader', 'Uploader')}>
|
||||
<Field label={t('mobilePhotos.uploader', 'Uploader')} color={text}>
|
||||
<input
|
||||
type="text"
|
||||
value={uploaderFilter}
|
||||
onChange={(e) => setUploaderFilter(e.target.value)}
|
||||
placeholder={t('events.photos.uploaderPlaceholder', 'Name or email')}
|
||||
style={inputStyle}
|
||||
placeholder={t('mobilePhotos.uploaderPlaceholder', 'Name or email')}
|
||||
style={baseInputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<XStack space="$2" alignItems="center">
|
||||
@@ -309,8 +332,8 @@ export default function MobileEventPhotosPage() {
|
||||
checked={onlyFeatured}
|
||||
onChange={(e) => setOnlyFeatured(e.target.checked)}
|
||||
/>
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{t('events.photos.onlyFeatured', 'Only featured')}
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{t('mobilePhotos.onlyFeatured', 'Only featured')}
|
||||
</Text>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
@@ -319,8 +342,8 @@ export default function MobileEventPhotosPage() {
|
||||
checked={onlyHidden}
|
||||
onChange={(e) => setOnlyHidden(e.target.checked)}
|
||||
/>
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{t('events.photos.onlyHidden', 'Only hidden')}
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{t('mobilePhotos.onlyHidden', 'Only hidden')}
|
||||
</Text>
|
||||
</label>
|
||||
</XStack>
|
||||
@@ -339,20 +362,10 @@ export default function MobileEventPhotosPage() {
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
function Field({ label, color, children }: { label: string; color: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
<Text fontSize="$sm" color={color}>
|
||||
{label}
|
||||
</Text>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user