diff --git a/client/src/app/(protectedRoute)/new-post/page.tsx b/client/src/app/(protectedRoute)/new-post/page.tsx index b324d4b..3e76d8b 100644 --- a/client/src/app/(protectedRoute)/new-post/page.tsx +++ b/client/src/app/(protectedRoute)/new-post/page.tsx @@ -13,19 +13,26 @@ import { Tooltip, Typography, } from "@mui/material"; + import GoBackIcon from "@/assets/icons/GoBackIcon.svg"; import InputSearchIcon from "@/assets/icons/InputSearchIcon.svg"; import AlcholeSearchIcon from "@/assets/icons/AlcholeSearchIcon.svg"; import { useRouter } from "next/navigation"; -import { ChangeEvent, useEffect, useMemo, useState } from "react"; -import axios from "@/libs/axios"; +import { ChangeEvent, useEffect, useState } from "react"; import HOME from "@/const/clientPath"; import CameraIcon from "@/assets/icons/CameraIcon.svg"; import PinIcon from "@/assets/icons/PinIcon.svg"; -import getTokenFromLocalStorage from "@/utils/getTokenFromLocalStorage"; import { useGlobalLoadingStore } from "@/store/useGlobalLoadingStore"; +import useNewPostMutation from "@/queries/newPost/useNewPostMutation"; +import useNewAttachMutation from "@/queries/attach/useNewAttachMutation"; +import { useInvalidatePostList } from "@/queries/post/useGetPostListInfiniteQuery"; +import { useDeletePostMutation } from "@/queries/post/useDeletePostMutation"; export default function NewpostPage() { + const { setLoading } = useGlobalLoadingStore(); + const router = useRouter(); + const invalidatePreviousPost = useInvalidatePostList(); + const [formValue, setFormValue] = useState({ postContent: "", postType: "BASIC", @@ -33,58 +40,10 @@ export default function NewpostPage() { tagList: [] as string[], }); - const changeHadler = ({ - target, - }: ChangeEvent) => { - setFormValue((prev) => ({ ...prev, [target.name]: target.value })); - }; - const { setLoading } = useGlobalLoadingStore(); - const token = getTokenFromLocalStorage(); - - const router = useRouter(); - - const submitHandler = () => { - let userId; - let pk; - setLoading(true); - axios - .get("/user/me", { headers: { Authorization: token } }) - .then((res) => { - userId = res.data.id; - }) - .then(() => { - axios - .post( - "/posts", - { ...formValue }, - { headers: { Authorization: token } } - ) - .then(({ data }) => { - pk = data.postNo; - const formData = new FormData(); - if (file) { - formData.append("image", file); - axios.post(`/attach/resources/POST/${pk}`, formData, { - headers: { - Authorization: token, - "Content-Type": "multipart/form-data", - }, - transformRequest: [ - function () { - return formData; - }, - ], - }); - } - setLoading(false); - router.push(HOME); - }); - }); - }; const [userTypedTag, setUserTypedTag] = useState(""); const [file, setFile] = useState(); const [fileUrl, setFileUrl] = useState(); - + const [isSuccess, SetIsSuccess] = useState(false); useEffect(() => { if (!file) { return; @@ -94,6 +53,42 @@ export default function NewpostPage() { reader.onloadend = () => setFileUrl(reader.result); }, [file]); + const changeHadler = ({ + target, + }: ChangeEvent) => { + setFormValue((prev) => ({ ...prev, [target.name]: target.value })); + }; + + const { mutateAsync: newPostHandler } = useNewPostMutation(); + const { mutateAsync: attachFileHandler } = useNewAttachMutation(); + const { mutateAsync: deletePostHandler } = useDeletePostMutation(); + + const submitHandler = async () => { + setLoading(true); + let postNo; + try { + const { postNo: res } = await newPostHandler(formValue); + postNo = res; + if (file) { + try { + await attachFileHandler({ + file, + url: { pk: postNo, type: "POST" }, + }); + } catch { + deletePostHandler(postNo); + return; + } + } + invalidatePreviousPost(); + SetIsSuccess(true); + router.push(HOME); + } catch { + return; + } finally { + setLoading(false); + } + }; return ( {/* 최상단 앱바 */} @@ -105,7 +100,12 @@ export default function NewpostPage() { 포스팅 - @@ -154,7 +154,11 @@ export default function NewpostPage() { {formValue.tagList.map((tag) => { - return #{tag}; + return ( + + #{tag} + + ); })} { e.preventDefault(); setFormValue((prev) => { - if (!userTypedTag) return prev; + if (!userTypedTag || prev.tagList.includes(userTypedTag)) { + setUserTypedTag(""); + return prev; + } return { ...prev, tagList: [...prev.tagList, userTypedTag] }; }); setUserTypedTag(""); @@ -177,6 +184,7 @@ export default function NewpostPage() { /> + {/* 파일 미리보기 */} {fileUrl && ( )} + {/* 버튼 그룹 */} + {/* 사진 */} + {/* 위치 */} void +"use client"; + +import ErrorPage from "@/components/ErrorPage"; + +export default function Error(props: { + error: Error & { digest?: string }; + reset: () => void; }) { - useEffect(() => { - // Log the error to an error reporting service - console.error(error) - }, [error]) - - return ( -
-

Something went wrong!

- -
- ) -} \ No newline at end of file + return ; +} diff --git a/client/src/components/DevelopingPage.tsx b/client/src/components/DevelopingPage.tsx index 33cb76d..59f5fde 100644 --- a/client/src/components/DevelopingPage.tsx +++ b/client/src/components/DevelopingPage.tsx @@ -13,7 +13,7 @@ const DevelopingPage = () => { height: "calc(100vh - 56px)", }} > - 개발중 알림 + 개발중 알림
); }; diff --git a/client/src/components/ErrorPage.tsx b/client/src/components/ErrorPage.tsx new file mode 100644 index 0000000..8754da9 --- /dev/null +++ b/client/src/components/ErrorPage.tsx @@ -0,0 +1,36 @@ +import { Button, Paper } from "@mui/material"; + +import hasErrorPage from "@/assets/images/hasError.png"; +import Image from "next/image"; +import { useEffect } from "react"; +import errorHandler from "@/utils/errorHandler"; + +const ErrorPage = ({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) => { + useEffect(() => { + errorHandler(JSON.stringify(error)); + }, [error]); + + return ( + + 에러임을 알림 + + + ); +}; + +export default ErrorPage; diff --git a/client/src/const/serverPath.ts b/client/src/const/serverPath.ts index 2835516..fe9c809 100644 --- a/client/src/const/serverPath.ts +++ b/client/src/const/serverPath.ts @@ -1,18 +1,37 @@ /** * 로그인 API Path */ -export const LOGIN_API_PATH = '/user/login' as const +export const LOGIN_API_PATH = "/user/login" as const; /** * 회원가입 API Path */ -export const SIGNUP_API_PATH = '/user/signup' as const +export const SIGNUP_API_PATH = "/user/signup" as const; /** * 내 정보를 받아오는 Path */ -export const MY_INFO = '/user/me' as const +export const MY_INFO = "/user/me" as const; /** * 쿠키를 심어주는 로그인 BFF */ -export const LOGIN_BFF = '/api/auth/login' as const \ No newline at end of file +export const LOGIN_BFF = "/api/auth/login" as const; + +/** + * 게시물리스트를 받아오거나, 작성하는 Path + */ +export const POST_LIST = "/posts" as const; +/** + * ID(pk) 를 입력받아 해당 포스트를 지우는 URL + */ +export const REMOVE_POST = (pk:number)=>`${POST_LIST}/${pk}` as const + +/** + * + * @param type : 리소스의 타입 POST|PROFILE|ALCOHOL + * @param resourcePk 등록하고자하는 게시글의 PK + * @returns + */ +export type ATTACH_FILE_ResourceType = "POST" | "PROFILE" | "ALCOHOL"; +export const ATTACH_FILE = (type: ATTACH_FILE_ResourceType, resourcePk: number) => + `/attach/resources/${type}/${resourcePk}` as const; diff --git a/client/src/libs/axios.ts b/client/src/libs/axios.ts index 6ff4921..f5807fa 100644 --- a/client/src/libs/axios.ts +++ b/client/src/libs/axios.ts @@ -1,3 +1,4 @@ +import getTokenFromLocalStorage from "@/utils/getTokenFromLocalStorage"; import axios from "axios"; axios.defaults.xsrfCookieName = "csrftoken"; @@ -7,10 +8,11 @@ axios.defaults.xsrfHeaderName = "x-CSRFToken"; * 쿠키를 싣고가는 요청 */ export const axiosPrivate = axios.create({ + baseURL: process.env.NEXT_PUBLIC_BASE_URL, headers: { "Content-Type": "application/json", + Authorization: getTokenFromLocalStorage(), }, - withCredentials: true, }); /** diff --git a/client/src/queries/attach/useNewAttachMutation.ts b/client/src/queries/attach/useNewAttachMutation.ts new file mode 100644 index 0000000..980d2ac --- /dev/null +++ b/client/src/queries/attach/useNewAttachMutation.ts @@ -0,0 +1,46 @@ +import { useMutation } from "@tanstack/react-query"; +import { axiosPrivate } from "@/libs/axios"; +import { ATTACH_FILE, ATTACH_FILE_ResourceType } from "@/const/serverPath"; + +export const useNewAttachMutation = () => { + return useMutation({ + mutationFn: async (param: { file: File; url: NewAttatchRequestUrl }) => + await postImageFn(param.file, param.url), + }); +}; + +interface NewAttatchRequestUrl { + type: ATTACH_FILE_ResourceType; + pk: number; +} +/** + * [Post] 파일을 업로드하는 axios요청 + * @param file 파일 + * @param param1 {type: "POST" | "PROFILE" | "ALCOHOL", pk : 게시글 PK} + * @returns 에셋 PK + */ +export const postImageFn = async ( + file: File, + { type, pk }: NewAttatchRequestUrl +) => { + const formData = new FormData(); + formData.append("image", file); + + const { data } = await axiosPrivate.post<{ attachNo: number }>( + ATTACH_FILE(type, pk), + formData, + { + headers: { + "Content-Type": "multipart/form-data", + }, + transformRequest: [ + function () { + return formData; + }, + ], + } + ); + return data; +}; + +export default useNewAttachMutation; diff --git a/client/src/queries/newPost/useNewPostMutation.tsx b/client/src/queries/newPost/useNewPostMutation.tsx new file mode 100644 index 0000000..15887ef --- /dev/null +++ b/client/src/queries/newPost/useNewPostMutation.tsx @@ -0,0 +1,23 @@ +import { useMutation } from "@tanstack/react-query"; +import { axiosPrivate } from "@/libs/axios"; +import { POST_LIST } from "@/const/serverPath"; +import { NewPostRequestInterface } from "@/types/newPost/NewPostInterface"; + +const useNewPostMutation = () => { + return useMutation({ + mutationFn: async (formData: NewPostRequestInterface) => { + const data = await usePostNewPostFn(formData); + return data; + }, + }); +}; + +const usePostNewPostFn = async (formData: NewPostRequestInterface) => { + const { data } = await axiosPrivate.post<{ postNo: number }>( + POST_LIST, + formData + ); + return data; +}; + +export default useNewPostMutation; diff --git a/client/src/queries/post/useDeletePostMutation.ts b/client/src/queries/post/useDeletePostMutation.ts new file mode 100644 index 0000000..41bc682 --- /dev/null +++ b/client/src/queries/post/useDeletePostMutation.ts @@ -0,0 +1,17 @@ +import { REMOVE_POST } from "@/const/serverPath"; +import { axiosPrivate } from "@/libs/axios"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useInvalidatePostList } from "./useGetPostListInfiniteQuery"; + +export const useDeletePostMutation = () => { + const invalidatePreviousData = useInvalidatePostList(); + return useMutation({ + mutationFn: (pk: number) => deletePostFn(pk), + onSuccess: () => { + invalidatePreviousData(); + }, + }); +}; + +export const deletePostFn = (pk: number) => + axiosPrivate.delete(REMOVE_POST(pk)); diff --git a/client/src/queries/post/useGetPostListInfiniteQuery.tsx b/client/src/queries/post/useGetPostListInfiniteQuery.tsx index e352887..38f554e 100644 --- a/client/src/queries/post/useGetPostListInfiniteQuery.tsx +++ b/client/src/queries/post/useGetPostListInfiniteQuery.tsx @@ -1,4 +1,4 @@ -import { useInfiniteQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import axios from "@/libs/axios"; import { PostInterface } from "@/types/post/PostInterface"; import { AxiosRequestConfig } from "axios"; @@ -90,4 +90,20 @@ export const getPostListInfiniteQueryKey = { byKeyword: (keyword?: string) => ["posts", keyword ?? ""] as const, }; +/** + * 모든 포스트리스트 쿼리를 Invalidate 하는 Hooks + * @returns Invalidate 함수 + */ +export const useInvalidatePostList = () => { + /** + * 모든 포스트리스트 쿼리를 Invalidate 하는함수 + */ + const queryClinet = useQueryClient(); + return () => { + queryClinet.invalidateQueries({ + queryKey: getPostListInfiniteQueryKey.all, + }); + }; +}; + export default useGetPostListInfiniteQuery; diff --git a/client/src/types/alcohol/AlcoholInterface.ts b/client/src/types/alcohol/AlcoholInterface.ts new file mode 100644 index 0000000..74b4ded --- /dev/null +++ b/client/src/types/alcohol/AlcoholInterface.ts @@ -0,0 +1,21 @@ +export interface AlcoholDetailResponseInterface { + alcoholNo: number; + alcoholTypeNo: number; + alcoholAttachUrls: AlcoholAttachUrlsInterface[]; + alcoholType: string; + alcoholName: string; + nickNames: string[]; + manufacturer: string; + description: string; + degree: number; + period: number; + productionYear: number; + volume: number; + tagList: string[]; +} + +export interface AlcoholAttachUrlsInterface { + attachNo: number; + attachUrl: string; + attachType: string; +} diff --git a/client/src/types/newPost/NewPostInterface.ts b/client/src/types/newPost/NewPostInterface.ts new file mode 100644 index 0000000..c102ded --- /dev/null +++ b/client/src/types/newPost/NewPostInterface.ts @@ -0,0 +1,31 @@ +export interface NewPostRequestInterface { + /** + * 술의 PK + */ + alcoholNo?: number; + alcoholInfo?: AlcoholInfoInterface; + alcoholFeature?: string; + postContent?: string; + postType?: string; + positionInfo?: string; + tagList?: string[]; +} + +interface AlcoholInfoInterface { + /** + * 술 이름 + */ + alcoholName: string; + /** + * DB에 지정된 Enum(?) + */ + alcoholTypeNo: number; + nickNames?: string[]; + manufacturer?: string; + description?: string; + degree?: number; + period?: number; + productionYear?: number; + volume?: number; + tagList?: string[]; +}