feat(mobile): add post creation and comments
Post creation: gallery/camera picker (expo-image-picker) uploads each image straight to Supabase Storage via a presigned PUT (posts.getPresignedUrl + expo-file-system), then posts.create with the resulting storage keys — reachable via a new "+" header button on the Feed tab. No client-side blurhash (browser Canvas API, no RN equivalent) or AI-disclosure/mention support yet, matching the rest of mobile's plain-text captions. Comments: a dedicated screen (posts/[postId]/comments) for reading, posting, and deleting one's own comments, reachable by tapping the comment icon on a PostCard. Ported from web's PostDetailDialog comment panel, scoped down to just that panel since mobile's image full-view already has its own route.
This commit is contained in:
1 parent
c1067b3245
commit
085d2abb53
10 files changed
+543
-15
No files matched your search
+8
-1
@@ -31,7 +31,14 @@
|
||||
"expo-font",
|
||||
"expo-dev-client",
|
||||
"expo-image",
|
||||
"expo-system-ui"
|
||||
"expo-system-ui",
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Erlaube PawFeed den Zugriff auf deine Fotos, um sie in einem Beitrag zu teilen.",
|
||||
"cameraPermission": "Erlaube PawFeed den Zugriff auf die Kamera, um ein Foto für deinen Beitrag aufzunehmen."
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { View, Text, Pressable, StyleSheet } from "react-native";
|
||||
import { Tabs, useRouter } from "expo-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Home, Compass, Bell, Heart, MessageCircle, PawPrint } from "lucide-react-native";
|
||||
import { Home, Compass, Bell, Heart, MessageCircle, PawPrint, Plus } from "lucide-react-native";
|
||||
import { useTRPC } from "../../../src/api/trpc";
|
||||
import { useActivePet } from "../../../src/context/ActivePetContext";
|
||||
import { colors, fontFamily } from "../../../src/theme/tokens";
|
||||
@@ -31,6 +31,12 @@ export default function TabsLayout() {
|
||||
name="index"
|
||||
options={{
|
||||
headerTitle: () => <Wordmark />,
|
||||
headerRight: () => (
|
||||
<View style={styles.headerButtons}>
|
||||
<NewPostHeaderButton />
|
||||
<MessagesHeaderButton />
|
||||
</View>
|
||||
),
|
||||
tabBarLabel: "Feed",
|
||||
tabBarIcon: ({ color, size }) => <Home color={color} size={size} />,
|
||||
}}
|
||||
@@ -61,6 +67,15 @@ function Wordmark() {
|
||||
);
|
||||
}
|
||||
|
||||
function NewPostHeaderButton() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Pressable onPress={() => router.push("/post-new")} hitSlop={12}>
|
||||
<Plus color={colors.ink} size={22} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function MessagesHeaderButton() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
@@ -92,6 +107,7 @@ function NotificationsTabIcon({ color, size }: { color: string; size: number })
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerButtons: { flexDirection: "row", alignItems: "center", gap: 16 },
|
||||
headerButton: { marginRight: 16 },
|
||||
wordmark: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||
wordmarkText: { fontFamily: fontFamily.bold, fontSize: 20, color: colors.ink },
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { Pressable } from "react-native";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { X } from "lucide-react-native";
|
||||
import { colors, fontFamily } from "../../src/theme/tokens";
|
||||
|
||||
const headerOptions = {
|
||||
@@ -7,6 +9,15 @@ const headerOptions = {
|
||||
headerTitleStyle: { fontFamily: fontFamily.bold },
|
||||
} as const;
|
||||
|
||||
function CloseModalButton() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Pressable onPress={() => router.back()} hitSlop={12} style={{ marginLeft: 8 }}>
|
||||
<X color={colors.ink} size={22} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
@@ -16,6 +27,16 @@ export default function AppLayout() {
|
||||
<Stack.Screen name="pets/[petId]/edit" options={{ ...headerOptions, title: "Profil bearbeiten" }} />
|
||||
<Stack.Screen name="pets/[petId]/followers" options={{ ...headerOptions, title: "Follower" }} />
|
||||
<Stack.Screen name="pets/[petId]/following" options={{ ...headerOptions, title: "Folgt" }} />
|
||||
<Stack.Screen name="posts/[postId]/comments" options={{ ...headerOptions, title: "Kommentare" }} />
|
||||
<Stack.Screen
|
||||
name="post-new"
|
||||
options={{
|
||||
...headerOptions,
|
||||
title: "Neuer Beitrag",
|
||||
presentation: "modal",
|
||||
headerLeft: () => <CloseModalButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="image-viewer"
|
||||
options={{ headerShown: false, presentation: "fullScreenModal", animation: "fade" }}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { View, Text, ScrollView, Image as RNImage, Pressable, ActivityIndicator, StyleSheet, Alert } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { X, ImagePlus, Camera } from "lucide-react-native";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { uploadImageToSupabase } from "../../src/lib/upload";
|
||||
import { TextField } from "../../src/components/TextField";
|
||||
import { Button } from "../../src/components/Button";
|
||||
import { colors, radii, typography } from "../../src/theme/tokens";
|
||||
|
||||
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
const MAX_IMAGES = 10;
|
||||
|
||||
type PickedImage = {
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
key: string | null;
|
||||
status: "uploading" | "done" | "error";
|
||||
};
|
||||
|
||||
/**
|
||||
* Port of web's PhotoPostForm + MultiImageUpload, scoped down for mobile:
|
||||
* pick from gallery or camera (expo-image-picker), upload each straight to
|
||||
* Supabase Storage via a presigned PUT (posts.getPresignedUrl), then
|
||||
* posts.create with the resulting storage keys. No blurhash placeholder
|
||||
* (that's computed via a browser Canvas on web — no RN equivalent here) and
|
||||
* no AI-disclosure/mention support yet, matching the rest of mobile's
|
||||
* currently-plain-text captions.
|
||||
*/
|
||||
export default function NewPostScreen() {
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { activePetId } = useActivePet();
|
||||
|
||||
const [images, setImages] = useState<PickedImage[]>([]);
|
||||
const [caption, setCaption] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const getPresignedUrlMutation = useMutation(trpc.posts.getPresignedUrl.mutationOptions());
|
||||
|
||||
const createMutation = useMutation(
|
||||
trpc.posts.create.mutationOptions({
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(trpc.feed.pathFilter());
|
||||
router.back();
|
||||
},
|
||||
onError: (err) => setError(err.message),
|
||||
})
|
||||
);
|
||||
|
||||
const uploadAsset = useCallback(
|
||||
async (asset: ImagePicker.ImagePickerAsset, index: number) => {
|
||||
const mimeType = asset.mimeType ?? "image/jpeg";
|
||||
try {
|
||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||
petId: activePetId!,
|
||||
contentType: mimeType as "image/jpeg" | "image/png" | "image/webp",
|
||||
fileSize: asset.fileSize ?? 0,
|
||||
});
|
||||
await uploadImageToSupabase(asset.uri, presignedUrl, mimeType);
|
||||
setImages((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = { ...updated[index], status: "done", key };
|
||||
return updated;
|
||||
});
|
||||
} catch {
|
||||
setImages((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = { ...updated[index], status: "error" };
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
},
|
||||
[activePetId, getPresignedUrlMutation]
|
||||
);
|
||||
|
||||
const addAssets = useCallback(
|
||||
(assets: ImagePicker.ImagePickerAsset[]) => {
|
||||
const available = MAX_IMAGES - images.length;
|
||||
if (available <= 0) {
|
||||
Alert.alert("Maximal 10 Fotos pro Beitrag.");
|
||||
return;
|
||||
}
|
||||
|
||||
const accepted = assets
|
||||
.filter((asset) => ALLOWED_MIME_TYPES.includes(asset.mimeType ?? "image/jpeg"))
|
||||
.slice(0, available);
|
||||
|
||||
if (accepted.length === 0) return;
|
||||
|
||||
const startIndex = images.length;
|
||||
const newImages: PickedImage[] = accepted.map((asset) => ({
|
||||
uri: asset.uri,
|
||||
mimeType: asset.mimeType ?? "image/jpeg",
|
||||
key: null,
|
||||
status: "uploading",
|
||||
}));
|
||||
setImages((prev) => [...prev, ...newImages]);
|
||||
accepted.forEach((asset, i) => uploadAsset(asset, startIndex + i));
|
||||
},
|
||||
[images.length, uploadAsset]
|
||||
);
|
||||
|
||||
const pickFromGallery = useCallback(async () => {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert("Zugriff auf Fotos wurde nicht erlaubt.");
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ["images"],
|
||||
allowsMultipleSelection: true,
|
||||
selectionLimit: MAX_IMAGES - images.length,
|
||||
quality: 0.8,
|
||||
});
|
||||
if (!result.canceled) addAssets(result.assets);
|
||||
}, [images.length, addAssets]);
|
||||
|
||||
const takePhoto = useCallback(async () => {
|
||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert("Zugriff auf die Kamera wurde nicht erlaubt.");
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchCameraAsync({ quality: 0.8 });
|
||||
if (!result.canceled) addAssets(result.assets);
|
||||
}, [addAssets]);
|
||||
|
||||
const removeImage = useCallback((index: number) => {
|
||||
setImages((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const doneImages = images.filter((img) => img.status === "done" && img.key);
|
||||
const isUploading = images.some((img) => img.status === "uploading");
|
||||
const canSubmit = doneImages.length > 0 && !isUploading && !createMutation.isPending;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!activePetId || doneImages.length === 0) return;
|
||||
setError(null);
|
||||
createMutation.mutate({
|
||||
petId: activePetId,
|
||||
caption: caption.trim() || null,
|
||||
images: doneImages.map((img) => ({ key: img.key as string, blurhash: null })),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
{images.length > 0 && (
|
||||
<View style={styles.grid}>
|
||||
{images.map((img, i) => (
|
||||
<View key={img.uri} style={styles.thumbWrap}>
|
||||
<RNImage source={{ uri: img.uri }} style={styles.thumb} />
|
||||
{img.status === "uploading" && (
|
||||
<View style={styles.thumbOverlay}>
|
||||
<ActivityIndicator color={colors.pureWhite} />
|
||||
</View>
|
||||
)}
|
||||
{img.status === "error" && (
|
||||
<View style={[styles.thumbOverlay, styles.thumbError]}>
|
||||
<Text style={styles.thumbErrorText}>Fehler</Text>
|
||||
</View>
|
||||
)}
|
||||
{img.status !== "uploading" && (
|
||||
<Pressable style={styles.removeButton} onPress={() => removeImage(i)} hitSlop={8}>
|
||||
<X color={colors.pureWhite} size={14} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{images.length < MAX_IMAGES && (
|
||||
<View style={styles.pickerRow}>
|
||||
<Pressable style={styles.pickerButton} onPress={pickFromGallery}>
|
||||
<ImagePlus color={colors.marigoldOrange} size={22} />
|
||||
<Text style={styles.pickerText}>Galerie</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.pickerButton} onPress={takePhoto}>
|
||||
<Camera color={colors.marigoldOrange} size={22} />
|
||||
<Text style={styles.pickerText}>Kamera</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
placeholder="Schreibe eine Bildunterschrift…"
|
||||
value={caption}
|
||||
onChangeText={setCaption}
|
||||
maxLength={500}
|
||||
multiline
|
||||
style={styles.caption}
|
||||
/>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Button
|
||||
title={createMutation.isPending ? "Wird geteilt…" : "Teilen"}
|
||||
onPress={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 16, gap: 12, backgroundColor: colors.porchWhite, flexGrow: 1 },
|
||||
grid: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
||||
thumbWrap: { width: 88, height: 88, borderRadius: radii.md, overflow: "hidden", backgroundColor: colors.hairlineGray },
|
||||
thumb: { width: "100%", height: "100%" },
|
||||
thumbOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.4)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
thumbError: { backgroundColor: "rgba(220,38,38,0.6)" },
|
||||
thumbErrorText: { color: colors.pureWhite, fontSize: 11, fontFamily: typography.label.fontFamily },
|
||||
removeButton: {
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
pickerRow: { flexDirection: "row", gap: 12 },
|
||||
pickerButton: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
height: 48,
|
||||
borderRadius: radii.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
},
|
||||
pickerText: { ...typography.body, color: colors.ink },
|
||||
caption: { height: 88, textAlignVertical: "top", paddingTop: 12 },
|
||||
error: { ...typography.body, color: colors.alertRed },
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { View, Text, FlatList, ActivityIndicator, Pressable, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react-native";
|
||||
import { useTRPC } from "../../../../src/api/trpc";
|
||||
import { useActivePet } from "../../../../src/context/ActivePetContext";
|
||||
import { PetAvatar } from "../../../../src/components/PetAvatar";
|
||||
import { TextField } from "../../../../src/components/TextField";
|
||||
import { Button } from "../../../../src/components/Button";
|
||||
import { formatPetName, formatRelativeTime } from "../../../../src/lib/format";
|
||||
import { colors, typography } from "../../../../src/theme/tokens";
|
||||
import type { PostComment } from "../../../../src/api/types";
|
||||
|
||||
/**
|
||||
* Read + write comments for a post, ported from web's PostDetailDialog
|
||||
* comments panel (src/components/feed/PostDetailDialog.tsx) but scoped to
|
||||
* just that: this is a dedicated screen, not a shared image+comments dialog
|
||||
* (the image already has its own full-view route, image-viewer.tsx).
|
||||
*/
|
||||
export default function CommentsScreen() {
|
||||
const { postId } = useLocalSearchParams<{ postId: string }>();
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { activePetId } = useActivePet();
|
||||
const listRef = useRef<FlatList<PostComment>>(null);
|
||||
|
||||
const [body, setBody] = useState("");
|
||||
const [pendingBody, setPendingBody] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery(trpc.comments.listByPost.queryOptions({ postId, limit: 50 }));
|
||||
const comments = data?.comments ?? [];
|
||||
|
||||
const invalidateComments = () =>
|
||||
queryClient.invalidateQueries({ queryKey: trpc.comments.listByPost.queryOptions({ postId, limit: 50 }).queryKey });
|
||||
|
||||
const createMutation = useMutation(
|
||||
trpc.comments.create.mutationOptions({
|
||||
onSuccess: async () => {
|
||||
setPendingBody(null);
|
||||
await invalidateComments();
|
||||
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100);
|
||||
},
|
||||
onError: () => {
|
||||
setBody((prev) => prev || (pendingBody ?? ""));
|
||||
setPendingBody(null);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const deleteMutation = useMutation(
|
||||
trpc.comments.delete.mutationOptions({ onSuccess: () => invalidateComments() })
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!body.trim() || !activePetId) return;
|
||||
const trimmed = body.trim();
|
||||
setPendingBody(trimmed);
|
||||
setBody("");
|
||||
createMutation.mutate({ postId, petId: activePetId, body: trimmed });
|
||||
}, [body, activePetId, postId, createMutation]);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.flex}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
keyboardVerticalOffset={80}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={comments}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.center}>
|
||||
<Text style={typography.body}>Noch keine Kommentare.</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<CommentRow
|
||||
comment={item}
|
||||
isOwn={item.pet.id === activePetId}
|
||||
onDelete={() => activePetId && deleteMutation.mutate({ commentId: item.id, petId: activePetId })}
|
||||
onPressPet={() => router.push(`/pets/${item.pet.id}`)}
|
||||
/>
|
||||
)}
|
||||
ListFooterComponent={
|
||||
pendingBody ? (
|
||||
<View style={[styles.row, styles.pending]}>
|
||||
<PetAvatar name="…" size="sm" />
|
||||
<View style={styles.rowBody}>
|
||||
<Text style={styles.pendingLabel}>Wird gesendet…</Text>
|
||||
<Text style={typography.body}>{pendingBody}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activePetId && (
|
||||
<View style={styles.inputRow}>
|
||||
<TextField
|
||||
placeholder="Kommentar schreiben…"
|
||||
value={body}
|
||||
onChangeText={setBody}
|
||||
maxLength={500}
|
||||
style={styles.input}
|
||||
/>
|
||||
<Button
|
||||
title={createMutation.isPending ? "…" : "Senden"}
|
||||
onPress={handleSubmit}
|
||||
disabled={!body.trim() || createMutation.isPending}
|
||||
style={styles.sendButton}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
type CommentRowProps = {
|
||||
comment: PostComment;
|
||||
isOwn: boolean;
|
||||
onDelete: () => void;
|
||||
onPressPet: () => void;
|
||||
};
|
||||
|
||||
function CommentRow({ comment, isOwn, onDelete, onPressPet }: CommentRowProps) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Pressable onPress={onPressPet}>
|
||||
<PetAvatar name={comment.pet.name} avatarKey={comment.pet.avatarKey} size="sm" />
|
||||
</Pressable>
|
||||
<View style={styles.rowBody}>
|
||||
<View style={styles.rowHeader}>
|
||||
<Pressable onPress={onPressPet}>
|
||||
<Text style={styles.petName}>{formatPetName(comment.pet)}</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.timestamp}>{formatRelativeTime(comment.createdAt)}</Text>
|
||||
</View>
|
||||
<Text style={typography.body}>{comment.body}</Text>
|
||||
</View>
|
||||
{isOwn && (
|
||||
<Pressable onPress={onDelete} hitSlop={8} style={styles.deleteButton}>
|
||||
<Trash2 color={colors.quietGray} size={16} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: { flex: 1, backgroundColor: colors.porchWhite },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
||||
list: { padding: 16, flexGrow: 1, gap: 14 },
|
||||
row: { flexDirection: "row", gap: 10 },
|
||||
pending: { opacity: 0.6, marginTop: 14 },
|
||||
rowBody: { flex: 1, gap: 2 },
|
||||
rowHeader: { flexDirection: "row", alignItems: "baseline", gap: 8 },
|
||||
petName: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 13 },
|
||||
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
|
||||
pendingLabel: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
|
||||
deleteButton: { alignSelf: "flex-start", padding: 4 },
|
||||
inputRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.hairlineGray,
|
||||
backgroundColor: colors.pureWhite,
|
||||
},
|
||||
input: { flex: 1 },
|
||||
sendButton: { width: 88 },
|
||||
});
|
||||
Generated
+33
-10
@@ -18,8 +18,10 @@
|
||||
"expo": "~57.0.18",
|
||||
"expo-constants": "~57.0.16",
|
||||
"expo-dev-client": "~57.0.18",
|
||||
"expo-file-system": "~57.0.6",
|
||||
"expo-font": "~57.0.3",
|
||||
"expo-image": "~57.0.4",
|
||||
"expo-image-picker": "~57.0.16",
|
||||
"expo-linking": "~57.0.8",
|
||||
"expo-router": "^5.1.11",
|
||||
"expo-secure-store": "~57.0.2",
|
||||
@@ -5755,6 +5757,16 @@
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-file-system": {
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.6.tgz",
|
||||
"integrity": "sha512-pm8PMYEW6BnVOCBJ7df9FcDmQtE1tqImuYphlfYe1ipQRLYtdCayRczJcbKIsnB3mqEyp4gC2gWmMbsAsAOjVg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-font": {
|
||||
"version": "57.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.3.tgz",
|
||||
@@ -5789,6 +5801,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-loader": {
|
||||
"version": "57.0.1",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.1.tgz",
|
||||
"integrity": "sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-picker": {
|
||||
"version": "57.0.16",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.16.tgz",
|
||||
"integrity": "sha512-9CrY/hoBHI2GsOo/4AWz6cInRHhGhhCmbza3JYr4j3P/NWpthLlIm0IXSWpxadSYJx0a2FAti0f7XWMg95aPEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expo-image-loader": "~57.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-json-utils": {
|
||||
"version": "57.0.1",
|
||||
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-57.0.1.tgz",
|
||||
@@ -6169,16 +6202,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/expo-file-system": {
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.6.tgz",
|
||||
"integrity": "sha512-pm8PMYEW6BnVOCBJ7df9FcDmQtE1tqImuYphlfYe1ipQRLYtdCayRczJcbKIsnB3mqEyp4gC2gWmMbsAsAOjVg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/expo-keep-awake": {
|
||||
"version": "57.0.1",
|
||||
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.1.tgz",
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
"expo": "~57.0.18",
|
||||
"expo-constants": "~57.0.16",
|
||||
"expo-dev-client": "~57.0.18",
|
||||
"expo-file-system": "~57.0.6",
|
||||
"expo-font": "~57.0.3",
|
||||
"expo-image": "~57.0.4",
|
||||
"expo-image-picker": "~57.0.16",
|
||||
"expo-linking": "~57.0.8",
|
||||
"expo-router": "^5.1.11",
|
||||
"expo-secure-store": "~57.0.2",
|
||||
|
||||
@@ -8,3 +8,4 @@ export type FeedPost = RouterOutputs["feed"]["getFeed"]["posts"][number];
|
||||
export type ExplorePet = RouterOutputs["explore"]["listPets"]["pets"][number];
|
||||
export type NotificationItem = RouterOutputs["notifications"]["list"]["notifications"][number];
|
||||
export type Conversation = RouterOutputs["conversations"]["list"][number];
|
||||
export type PostComment = RouterOutputs["comments"]["listByPost"]["comments"][number];
|
||||
@@ -92,10 +92,10 @@ export function PostCard({ post, actingPetId }: Props) {
|
||||
|
||||
<View style={styles.actions}>
|
||||
<PawButton postId={post.id} petId={actingPetId} initialCount={post.reactionCount ?? 0} />
|
||||
<View style={styles.actionGroup}>
|
||||
<Pressable style={styles.actionGroup} onPress={() => router.push(`/posts/${post.id}/comments`)}>
|
||||
<MessageCircle size={18} color={colors.quietGray} />
|
||||
<Text style={styles.actionCount}>{post.commentCount ?? 0}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<View style={styles.actionGroup}>
|
||||
<Repeat2 size={18} color={colors.quietGray} />
|
||||
<Text style={styles.actionCount}>{post.repostCount ?? 0}</Text>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { File } from "expo-file-system";
|
||||
|
||||
/**
|
||||
* PUTs a local picked-image file directly to the Supabase Storage presigned
|
||||
* URL returned by `posts.getPresignedUrl` — mirrors web's XHR PUT in
|
||||
* `src/lib/upload-xhr.ts`, using expo-file-system's `File.upload` instead
|
||||
* since RN has no XHR-with-progress primitive by default. Content-Type must
|
||||
* exactly match what was passed to `getPresignedUrl` or Supabase responds
|
||||
* with 400 SignatureDoesNotMatch.
|
||||
*/
|
||||
export async function uploadImageToSupabase(uri: string, presignedUrl: string, mimeType: string): Promise<void> {
|
||||
const file = new File(uri);
|
||||
const result = await file.upload(presignedUrl, {
|
||||
httpMethod: "PUT",
|
||||
mimeType,
|
||||
headers: { "Content-Type": mimeType },
|
||||
});
|
||||
|
||||
if (result.status < 200 || result.status >= 300) {
|
||||
throw new Error(`Upload fehlgeschlagen (Status ${result.status})`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user