weitere perfektionierung der neuen mobile app

This commit is contained in:
Codex Agent
2025-12-11 12:18:08 +01:00
parent 7b01a77083
commit b4417db5cd
38 changed files with 4265 additions and 3040 deletions

View File

@@ -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}