강의

멘토링

로드맵

Inflearn brand logo image

인프런 커뮤니티 질문&답변

dbrud5804님의 프로필 이미지
dbrud5804

작성한 질문수

React Native with Expo: 제로초에게 제대로 배우기

이미지 업로드 하기(form-data)

게시글 post 버튼을 클릭한 후에 화면 이동이 발생하지 않습니다

작성

·

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

dbrud5804님의 프로필 이미지
dbrud5804
질문자

  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 메세지가 또 안뜨네요...

dbrud5804님의 프로필 이미지
dbrud5804
질문자

  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: 3000,
    });

    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,
        });
        router.replace(`/@${data[0].userId}/post/${data[0].id}`);
      })
      .catch((err) => {
        console.error("post error", err);
        Toast.hide();
        Toast.show({
          text1: "Post failed!",
          type: "error",
          visibilityTime: 3000,
        });
      });
  };


이렇게 handlePost 수정하니까 toast 메세지도 뜨고 게시글 작성된 페이지로 돌아갑니다..!

0

dbrud5804님의 프로필 이미지
dbrud5804
질문자

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

dbrud5804님의 프로필 이미지
dbrud5804
질문자

  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

dbrud5804님의 프로필 이미지
dbrud5804
질문자

길어서 나눠서 올렸습니다..!

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
      )
    );
  };

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

토스트가 뜨면 토스트 클릭하면 이동하지 않나요?

dbrud5804님의 프로필 이미지
dbrud5804
질문자

지금 토스트도 안 뜨는 상황입니다 ㅜㅜ

0

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

Modal 코드 보여주세요

dbrud5804님의 프로필 이미지
dbrud5804
질문자

코드가 길어져서 나눠서 올렸습니다!

dbrud5804님의 프로필 이미지
dbrud5804

작성한 질문수

질문하기