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:
admin committed 2026-09-05 15:35:32 +02:00
1 parent c1067b3245
commit 085d2abb53
10 files changed
+543 -15

No files matched your search

+8 -1
View File
@@ -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."
}
]
]
}
}
+17 -1
View File
@@ -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 },
+22 -1
View File
@@ -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" }}
+254
View File
@@ -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 },
});
+33 -10
View File
@@ -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",
+2
View File
@@ -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",
+1
View File
@@ -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];
+2 -2
View File
@@ -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>
+22
View File
@@ -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})`);
}
}