chahinebrini 692abc07e1 fix(ui): Teilen-Button ohne Icon h:44, Dropdown-Width 170→210pt
ComposeCard:
- paper-plane-Icon entfernt, h:52→44, px-6→5, layout flex-row gap-2 →
  items-center justify-center, only Text. Saubere Pill ohne Icon-Gewicht.
- i18n-Key community.share war schon da — kein neuer Key.

HeaderDropdownMenu:
- minWidth 170→210, numberOfLines={1} auf Item-Labels entfernt (Breite
  reicht jetzt fuer alle Labels). numberOfLines bei SOS-Block-Labels
  bleibt (zwei separate Text-Nodes, sinnvoll).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:36:37 +02:00

184 lines
6.4 KiB
TypeScript

import { useState, useRef } from 'react';
import {
View,
Text,
TextInput,
Pressable,
Image,
ActivityIndicator,
Alert,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import * as FileSystem from 'expo-file-system';
import * as ImagePicker from 'expo-image-picker';
import { apiFetch } from '../lib/api';
import { resolveAvatar } from '../lib/resolveAvatar';
import { useAuthStore } from '../stores/auth';
import { colors } from '../lib/theme';
type Props = {
onPosted?: () => void;
};
export function ComposeCard({ onPosted }: Props) {
const { t } = useTranslation();
const { user } = useAuthStore();
const queryClient = useQueryClient();
const inputRef = useRef<TextInput>(null);
const [focused, setFocused] = useState(false);
const [content, setContent] = useState('');
const [imageUri, setImageUri] = useState<string | null>(null);
const [posting, setPosting] = useState(false);
const avatarId = user?.user_metadata?.avatar_id as string | undefined;
const nickname = (user?.user_metadata?.username as string | undefined) ?? t('community.compose_default_user');
const avatarUrl = resolveAvatar(avatarId ?? null, nickname);
const cancel = () => {
setContent('');
setImageUri(null);
setFocused(false);
inputRef.current?.blur();
};
const pickImage = async () => {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert(t('community.compose_photo_perm_title'), t('community.compose_photo_perm_desc'));
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: false,
quality: 0.8,
});
if (!result.canceled && result.assets[0]?.uri) {
setImageUri(result.assets[0].uri);
}
};
const submit = async () => {
if (!content.trim() && !imageUri) return;
setPosting(true);
try {
let uploadedImageUrl: string | undefined;
if (imageUri) {
const base64 = await FileSystem.readAsStringAsync(imageUri, {
encoding: FileSystem.EncodingType.Base64,
});
const upload = await apiFetch<{ url: string }>('/api/community/upload-image', {
method: 'POST',
body: {
image: `data:image/jpeg;base64,${base64}`,
mimeType: 'image/jpeg',
},
});
uploadedImageUrl = upload?.url;
}
await apiFetch('/api/community/post', {
method: 'POST',
body: {
category: 'story',
content: content.trim(),
...(uploadedImageUrl ? { imageUrl: uploadedImageUrl } : {}),
},
});
cancel();
queryClient.invalidateQueries({ queryKey: ['community-posts'] });
onPosted?.();
} catch (err: any) {
Alert.alert(t('common.error'), err?.message ?? t('community.post_failed'));
} finally {
setPosting(false);
}
};
const showActions = focused || content.length > 0;
return (
<View className="bg-white border border-neutral-200 rounded-2xl p-4 mb-4">
<View className="flex-row items-start gap-3">
<Image
source={{ uri: avatarUrl }}
className="w-8 h-8 rounded-full bg-neutral-100 shrink-0 mt-0.5"
/>
<View className="flex-1 min-w-0">
<TextInput
ref={inputRef}
value={content}
onChangeText={setContent}
onFocus={() => setFocused(true)}
placeholder={t('community.compose_placeholder')}
placeholderTextColor="#a3a3a3"
multiline
className="text-sm text-neutral-900 leading-5 min-h-[40px]"
style={{ textAlignVertical: 'top', fontFamily: 'Nunito_400Regular' }}
/>
{imageUri && (
<View className="relative mt-2">
<Image
source={{ uri: imageUri }}
className="w-full rounded-xl"
style={{ height: 160 }}
resizeMode="cover"
/>
<Pressable
onPress={() => setImageUri(null)}
hitSlop={{ top: 9, bottom: 9, left: 9, right: 9 }}
android_ripple={{ color: 'rgba(255,255,255,0.18)', borderless: true, radius: 22 }}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/50 items-center justify-center"
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<Ionicons name="close" size={14} color="#fff" />
</Pressable>
</View>
)}
</View>
</View>
{showActions && (
<View className="flex-row items-center justify-between mt-3 pt-3 border-t border-neutral-100">
<Pressable
onPress={pickImage}
android_ripple={{ color: 'rgba(0,0,0,0.08)', borderless: true, radius: 22 }}
className="flex-row items-center gap-1.5 px-2"
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1, height: 44, alignItems: 'center', justifyContent: 'center' })}
>
<Ionicons name="image-outline" size={22} color="#737373" />
<Text className="text-sm text-neutral-500" style={{ fontFamily: 'Nunito_400Regular' }}>{t('community.image')}</Text>
</Pressable>
<View className="flex-row items-center gap-2">
<Pressable
onPress={cancel}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
>
<Text className="text-sm text-neutral-400" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('common.cancel')}</Text>
</Pressable>
<Pressable
onPress={submit}
disabled={!content.trim() || posting}
className="px-5 rounded-full bg-rebreak-500 items-center justify-center"
style={({ pressed }) => ({
height: 44,
opacity: pressed || !content.trim() || posting ? 0.5 : 1,
})}
>
{posting ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_700Bold' }}>{t('community.share')}</Text>
)}
</Pressable>
</View>
</View>
)}
</View>
);
}