작성
·
38
0
안녕하세요.
게시글 post 버튼을 클릭한 후에 화면 이동이 발생하지 않습니다... 혹시 제가 코드 작성하면서 누락한 부분이 있는 건가 싶어서 어떤 코드에서 그 부분을 다루고 있는지 잘 모르겠어서 질문드립니다 ㅜㅜ
modal.tsx의 handlePost는 동일하게 작성되어 있습니다.
post 버튼을 누른뒤에 로그는 아래와 같이 정상적으로 출력되고, cancel 버튼을 눌러서 뒤로 가서 확인해보면 게시글은 잘 작성되어있습니다.. 화면이 자동으로 안넘어가는데 어디를 확인해봐야하나요??
LOG posts [{"content": "dd", "id": "1754381715692", "imageUrls": ["file:///data/user/0/com.cozyu.threadsclone/cache/ImagePicker/b22031f0-a977-4501-86c9-2cbc9cf73ae2.jpeg"], "userId": "cozyu"}]
LOG post result [{"content": "dd", "id": "1754381715692", "imageUrls": ["file:///data/user/0/com.cozyu.threadsclone/cache/ImagePicker/b22031f0-a977-4501-86c9-2cbc9cf73ae2.jpeg"], "userId": "cozyu"}]
답변 5
0
const handlePost = () => {
console.log("handlePost", threads);
const formData = new FormData();
threads.forEach((thread, index) => {
formData.append(`posts[${index}][id]`, thread.id);
formData.append(`posts[${index}][content]`, thread.text);
formData.append(`posts[${index}][userId]`, "cozyu");
formData.append(
`posts[${index}][location]`,
JSON.stringify(thread.location)
);
thread.imageUrls.forEach((imageUrl, imageIndex) => {
formData.append(`posts[${index}][imageUrls][${imageIndex}]`, {
uri: imageUrl,
name: `image_${index}_${imageIndex}.jpeg`,
type: "image/jpeg",
} as unknown as Blob);
});
});
// toast 메시지
Toast.show({
text1: "Posting...",
type: "info",
visibilityTime: 5000,
});
fetch("/posts", {
method: "POST",
headers: {
"Content-Type": "multipart/form-data",
},
body: formData,
})
.then((res) => res.json())
.then((data) => {
console.log("post result", data);
router.replace(`/@${data[0].userId}/post/${data[0].id}`);
Toast.hide();
Toast.show({
text1: "Posted successfully!",
type: "success",
visibilityTime: 3000,
});
})
.catch((err) => {
console.error("post error", err);
Toast.hide();
Toast.show({
text1: "Post failed!",
type: "error",
visibilityTime: 3000,
});
});
};
.then(data) 이후에 router.replace로 돌아가게는 할 수 있는 것 같은데 toast 메세지가 또 안뜨네요...
0
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
},
containerLight: {
backgroundColor: "#fff",
},
containerDark: {
backgroundColor: "#101010",
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
},
headerLight: {
backgroundColor: "#fff",
},
headerDark: {
backgroundColor: "#101010",
},
headerRightPlaceholder: {
width: 60,
},
cancel: {
fontSize: 16,
},
cancelLight: {
color: "#000",
},
cancelDark: {
color: "#fff",
},
disabledText: {
color: "#ccc",
},
title: {
fontSize: 16,
fontWeight: "600",
},
titleLight: {
color: "#000",
},
titleDark: {
color: "#fff",
},
list: {
flex: 1,
},
listLight: {
backgroundColor: "white",
},
listDark: {
backgroundColor: "#101010",
},
threadContainer: {
flexDirection: "row",
paddingHorizontal: 20,
paddingTop: 12,
},
avatarContainer: {
alignItems: "center",
marginRight: 12,
paddingTop: 2,
},
avatar: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "#555",
},
avatarSmall: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: "#555",
},
threadLine: {
width: 1.5,
flexGrow: 1,
backgroundColor: "#aaa",
marginTop: 8,
},
contentContainer: {
flex: 1,
paddingBottom: 6,
},
userInfoContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 2,
},
username: {
fontWeight: "600",
fontSize: 15,
},
usernameLight: {
color: "#000",
},
usernameDark: {
color: "#fff",
},
input: {
fontSize: 15,
paddingTop: 4,
paddingBottom: 8,
minHeight: 24,
lineHeight: 20,
},
inputLight: {
color: "#000",
},
inputDark: {
color: "#fff",
},
actionButtons: {
flexDirection: "row",
alignItems: "center",
},
actionButton: {
marginRight: 15,
},
imageFlatList: {
marginTop: 12,
marginBottom: 4,
},
imagePreviewContainer: {
position: "relative",
marginRight: 8,
width: 100,
height: 100,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#f0f0f0",
},
imagePreview: {
width: "100%",
height: "100%",
},
removeImageButton: {
position: "absolute",
top: 4,
right: 4,
backgroundColor: "rgba(255, 255, 255, 0.8)",
borderRadius: 12,
padding: 2,
},
footer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingTop: 10,
position: "absolute",
bottom: 0,
left: 0,
right: 0,
},
footerLight: {
backgroundColor: "white",
},
footerDark: {
backgroundColor: "#101010",
},
footerText: {
fontSize: 14,
},
footerTextLight: {
color: "#8e8e93",
},
footerTextDark: {
color: "#555",
},
postButton: {
paddingVertical: 8,
paddingHorizontal: 18,
borderRadius: 18,
},
postButtonLight: {
backgroundColor: "black",
},
postButtonDark: {
backgroundColor: "white",
},
postButtonDisabledLight: {
backgroundColor: "#ccc",
},
postButtonDisabledDark: {
backgroundColor: "#555",
},
postButtonText: {
fontSize: 15,
fontWeight: "600",
},
postButtonTextLight: {
color: "white",
},
postButtonTextDark: {
color: "black",
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.4)",
justifyContent: "flex-end",
},
dropdownContainer: {
width: 200,
borderRadius: 10,
marginHorizontal: 10,
overflow: "hidden",
},
dropdownContainerLight: {
backgroundColor: "white",
},
dropdownContainerDark: {
backgroundColor: "#101010",
},
dropdownOption: {
paddingVertical: 15,
paddingHorizontal: 20,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#e5e5e5",
},
selectedOption: {},
dropdownOptionText: {
fontSize: 16,
},
dropdownOptionTextLight: {
color: "#000",
},
dropdownOptionTextDark: {
color: "#fff",
},
selectedOptionText: {
fontWeight: "600",
color: "#007AFF",
},
removeButton: {
padding: 4,
marginRight: -4,
marginLeft: 8,
},
listFooter: {
paddingLeft: 26,
paddingTop: 10,
flexDirection: "row",
},
listFooterAvatar: {
marginRight: 20,
paddingTop: 2,
},
locationContainer: {
marginTop: 4,
marginBottom: 4,
},
locationText: {
fontSize: 14,
color: "#8e8e93",
},
});
0
const renderThreadItem = ({
item,
index,
}: {
item: Thread;
index: number;
}) => (
<View style={styles.threadContainer}>
<View style={styles.avatarContainer}>
<Image
source={require("../assets/images/avatar.png")}
style={styles.avatar}
/>
<View style={styles.threadLine} />
</View>
<View style={styles.contentContainer}>
<View style={styles.userInfoContainer}>
<Text
style={[
styles.username,
colorScheme === "dark"
? styles.usernameDark
: styles.usernameLight,
]}
>
cozyu
</Text>
{index > 0 && (
<TouchableOpacity
onPress={() => removeThread(item.id)}
style={styles.removeButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close-outline" size={20} color="#8e8e93" />
</TouchableOpacity>
)}
</View>
<TextInput
style={[
styles.input,
colorScheme === "dark" ? styles.inputDark : styles.inputLight,
]}
placeholder={"What's new?"}
placeholderTextColor="#999"
value={item.text}
onChangeText={(text) => updateThreadText(item.id, text)}
multiline
/>
{item.imageUrls && item.imageUrls.length > 0 && (
<FlatList
data={item.imageUrls}
renderItem={({ item: uri, index: imgIndex }) => (
<View style={styles.imagePreviewContainer}>
<Image source={{ uri }} style={styles.imagePreview} />
<TouchableOpacity
onPress={() =>
!isPosting && removeImageFromThread(item.id, uri)
}
style={styles.removeImageButton}
>
<Ionicons
name="close-circle"
size={20}
color="rgba(0,0,0,0.7)"
/>
</TouchableOpacity>
</View>
)}
keyExtractor={(uri, imgIndex) =>
`${item.id}-img-${imgIndex}-${uri}`
}
horizontal
showsHorizontalScrollIndicator={false}
style={styles.imageFlatList}
/>
)}
{item.location && (
<View style={styles.locationContainer}>
<Text style={styles.locationText}>
{item.location[0]}, {item.location[1]}
</Text>
</View>
)}
<View style={styles.actionButtons}>
<Pressable
style={styles.actionButton}
onPress={() => !isPosting && pickImage(item.id)}
>
<Ionicons name="image-outline" size={24} color="#777" />
</Pressable>
<Pressable
style={styles.actionButton}
onPress={() => !isPosting && takePhoto(item.id)}
>
<Ionicons name="camera-outline" size={24} color="#777" />
</Pressable>
<Pressable
style={styles.actionButton}
onPress={() => {
getMyLocation(item.id);
}}
>
<FontAwesome name="map-marker" size={24} color="#777" />
</Pressable>
</View>
</View>
</View>
);
return (
<View
style={[
styles.container,
{ paddingTop: insets.top },
colorScheme === "dark" ? styles.containerDark : styles.containerLight,
]}
>
<View
style={[
styles.header,
colorScheme === "dark" ? styles.headerDark : styles.headerLight,
]}
>
<Pressable onPress={handleCancel} disabled={isPosting}>
<Text
style={[
styles.cancel,
colorScheme === "dark" ? styles.cancelDark : styles.cancelLight,
isPosting && styles.disabledText,
]}
>
Cancel
</Text>
</Pressable>
<Text
style={[
styles.title,
colorScheme === "dark" ? styles.titleDark : styles.titleLight,
]}
>
New thread
</Text>
<View style={styles.headerRightPlaceholder} />
</View>
<FlatList
data={threads}
keyExtractor={(item) => item.id}
renderItem={renderThreadItem}
ListFooterComponent={
<ListFooter
canAddThread={canAddThread}
addThread={() => {
if (canAddThread) {
setThreads((prevThreads) => [
...prevThreads,
{ id: Date.now().toString(), text: "", imageUrls: [] },
]);
}
}}
/>
}
style={[
styles.list,
colorScheme === "dark" ? styles.listDark : styles.listLight,
]}
contentContainerStyle={{
backgroundColor: colorScheme === "dark" ? "#101010" : "white",
}}
keyboardShouldPersistTaps="handled"
/>
<RNModal
transparent={true}
visible={isDropdownVisible}
animationType="fade"
onRequestClose={() => setIsDropdownVisible(false)}
>
<Pressable
style={styles.modalOverlay}
onPress={() => setIsDropdownVisible(false)}
>
<View
style={[
styles.dropdownContainer,
{ bottom: insets.bottom + 30 },
colorScheme === "dark"
? styles.dropdownContainerDark
: styles.dropdownContainerLight,
]}
>
{replyOptions.map((option) => (
<Pressable
key={option}
style={[
styles.dropdownOption,
option === replyOption && styles.selectedOption,
]}
onPress={() => {
setReplyOption(option);
setIsDropdownVisible(false);
}}
>
<Text
style={[
styles.dropdownOptionText,
colorScheme === "dark"
? styles.dropdownOptionTextDark
: styles.dropdownOptionTextLight,
option === replyOption && styles.selectedOptionText,
]}
>
{option}
</Text>
</Pressable>
))}
</View>
</Pressable>
</RNModal>
<View
style={[
styles.footer,
{ paddingBottom: insets.bottom + 10 },
colorScheme === "dark" ? styles.footerDark : styles.footerLight,
]}
>
<Pressable onPress={() => setIsDropdownVisible(true)}>
<Text
style={[
styles.footerText,
colorScheme === "dark"
? styles.footerTextDark
: styles.footerTextLight,
]}
>
{replyOption} can reply & quote
</Text>
</Pressable>
<Pressable
style={[
styles.postButton,
colorScheme === "dark"
? styles.postButtonDark
: styles.postButtonLight,
!canPost &&
(colorScheme === "dark"
? styles.postButtonDisabledDark
: styles.postButtonDisabledLight),
]}
disabled={!canPost}
onPress={handlePost}
>
<Text
style={[
styles.postButtonText,
colorScheme === "dark"
? styles.postButtonTextDark
: styles.postButtonTextLight,
]}
>
Post
</Text>
</Pressable>
</View>
</View>
);
}
0
길어서 나눠서 올렸습니다..!
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import * as ImagePicker from "expo-image-picker";
import * as Location from "expo-location";
import * as MediaLibrary from "expo-media-library";
import { useRouter } from "expo-router";
import React, { useState } from "react";
import {
Alert,
FlatList,
Image,
Linking,
Pressable,
Modal as RNModal,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
useColorScheme,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Toast from "react-native-toast-message";
interface Thread {
id: string;
text: string;
hashtag?: string;
location?: [number, number];
imageUrls: string[];
}
export function ListFooter({
canAddThread,
addThread,
}: {
canAddThread: boolean;
addThread: () => void;
}) {
return (
<View style={styles.listFooter}>
<View style={styles.listFooterAvatar}>
<Image
source={require("../assets/images/avatar.png")}
style={styles.avatarSmall}
/>
</View>
<View>
<Pressable onPress={addThread} style={[styles.input]}>
<Text style={{ color: canAddThread ? "#999" : "#aaa" }}>
Add to thread
</Text>
</Pressable>
</View>
</View>
);
}
export default function Modal() {
const colorScheme = useColorScheme();
const router = useRouter();
const [threads, setThreads] = useState<Thread[]>([
{ id: Date.now().toString(), text: "", imageUrls: [] },
]);
const insets = useSafeAreaInsets();
const [replyOption, setReplyOption] = useState("Anyone");
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [isPosting, setIsPosting] = useState(false);
const replyOptions = ["Anyone", "Profiles you follow", "Mentioned only"];
const handleCancel = () => {
if (isPosting) return;
router.back();
};
const handlePost = () => {
console.log("handlePost", threads);
const formData = new FormData();
threads.forEach((thread, index) => {
formData.append(`posts[${index}][id]`, thread.id);
formData.append(`posts[${index}][content]`, thread.text);
formData.append(`posts[${index}][userId]`, "cozyu");
formData.append(
`posts[${index}][location]`,
JSON.stringify(thread.location)
);
thread.imageUrls.forEach((imageUrl, imageIndex) => {
formData.append(`posts[${index}][imageUrls][${imageIndex}]`, {
uri: imageUrl,
name: `image_${index}_${imageIndex}.jpeg`,
type: "image/jpeg",
} as unknown as Blob);
});
});
// toast 메시지
Toast.show({
text1: "Posting...",
type: "info",
visibilityTime: 5000,
});
fetch("/posts", {
method: "POST",
headers: {
"Content-Type": "multipart/form-data",
},
body: formData,
})
.then((res) => res.json())
.then((data) => {
console.log("post result", data);
Toast.hide();
Toast.show({
text1: "Posted successfully!",
type: "success",
visibilityTime: 3000,
});
})
.catch((err) => {
console.error("post error", err);
Toast.hide();
Toast.show({
text1: "Post failed!",
type: "error",
visibilityTime: 3000,
});
});
};
const updateThreadText = (id: string, text: string) => {
setThreads((prevThreads) =>
prevThreads.map((thread) =>
thread.id === id ? { ...thread, text } : thread
)
);
};
const canAddThread =
(threads.at(-1)?.text.trim().length ?? 0) > 0 ||
(threads.at(-1)?.imageUrls.length ?? 0) > 0;
const canPost = threads.every(
(thread) => thread.text.trim().length > 0 || thread.imageUrls.length > 0
);
const removeThread = (id: string) => {
setThreads((prevThreads) =>
prevThreads.filter((thread) => thread.id !== id)
);
};
const pickImage = async (id: string) => {
let { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== "granted") {
Alert.alert(
"Photos permission not granted",
"Please grant photos permission to use this feature",
[
{ text: "Open settings", onPress: () => Linking.openSettings() },
{
text: "Cancel",
},
]
);
return;
}
let result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images", "livePhotos", "videos"],
allowsMultipleSelection: true,
selectionLimit: 5,
});
console.log("image result", result);
if (!result.canceled) {
setThreads((prevThreads) =>
prevThreads.map((thread) =>
thread.id === id
? {
...thread,
imageUrls: thread.imageUrls.concat(
result.assets?.map((asset) => asset.uri) ?? []
),
}
: thread
)
);
}
};
const takePhoto = async (id: string) => {
let { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== "granted") {
Alert.alert(
"Camera permission not granted",
"Please grant camera permission to use this feature",
[
{ text: "Open settings", onPress: () => Linking.openSettings() },
{
text: "Cancel",
},
]
);
return;
}
let result = await ImagePicker.launchCameraAsync({
mediaTypes: ["images", "livePhotos", "videos"],
allowsMultipleSelection: true,
selectionLimit: 5,
});
console.log("camera result", result);
status = (await MediaLibrary.requestPermissionsAsync()).status;
if (status === "granted" && result.assets?.[0].uri) {
MediaLibrary.saveToLibraryAsync(result.assets[0].uri);
}
if (!result.canceled) {
setThreads((prevThreads) =>
prevThreads.map((thread) =>
thread.id === id
? {
...thread,
imageUrls: thread.imageUrls.concat(
result.assets?.map((asset) => asset.uri) ?? []
),
}
: thread
)
);
}
};
const removeImageFromThread = (id: string, uriToRemove: string) => {
setThreads((prevThreads) =>
prevThreads.map((thread) =>
thread.id === id
? {
...thread,
imageUrls: thread.imageUrls.filter((uri) => uri !== uriToRemove),
}
: thread
)
);
};
const getMyLocation = async (id: string) => {
let { status } = await Location.requestForegroundPermissionsAsync();
console.log("getMyLocation", status);
if (status !== "granted") {
Alert.alert(
"Location permission not granted",
"Please grant location permission to use this feature",
[
{
text: "Open settings",
onPress: () => {
Linking.openSettings();
},
},
{
text: "Cancel",
},
]
);
return;
}
const location = await Location.getCurrentPositionAsync({});
setThreads((prevThreads) =>
prevThreads.map((thread) =>
thread.id === id
? {
...thread,
location: [location.coords.latitude, location.coords.longitude],
}
: thread
)
);
};
이렇게 handlePost 수정하니까 toast 메세지도 뜨고 게시글 작성된 페이지로 돌아갑니다..!