Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,16 @@ week07/mission/Chap7_mission/src/App.css
week07/mission/Chap7_mission/src/index.css
week07/mission/Chap7_mission/src/vite-env.d.ts
week07/mission/Chap7_mission/src/hooks/queries/UMC_9th_web.code-workspace
week08/mission/Chap8_mission/.env
.gitignore
week08/mission/Chap8_mission/eslint.config.js
week08/mission/Chap8_mission/index.html
week08/mission/Chap8_mission/package-lock.json
week08/mission/Chap8_mission/package.json
week08/mission/Chap8_mission/README.md
week08/mission/Chap8_mission/tsconfig.app.json
week08/mission/Chap8_mission/tsconfig.json
week08/mission/Chap8_mission/tsconfig.node.json
week08/mission/Chap8_mission/vite.config.ts
week08/mission/Chap8_mission/src/apis/auth.ts
week08/mission/Chap8_mission/src/hooks/queries/UMC_9th_web.code-workspace
4 changes: 2 additions & 2 deletions week07/mission/Chap7_mission/src/apis/lp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,12 @@ export const deleteComment = async (lpId: string | undefined, commentId:number):
return data;
};

export const patchLp = async (lpId: string | undefined, body: RequestPatchLpDto ):Promise<ResponseLpDetailDto> => {
export const patchLp = async ({ lpId, body }: { lpId: string; body: RequestPatchLpDto } ):Promise<ResponseLpDetailDto> => {
const { data } = await axiosInstance.patch(`/v1/lps/${lpId}`, body);
return data;
};

export const deleteLp = async (lpId: string | undefined ):Promise<ResponseDeleteLpDto> => {
export const deleteLp = async ({lpId}: RequestLpDto ):Promise<ResponseDeleteLpDto> => {
const { data } = await axiosInstance.delete(`/v1/lps/${lpId}`);
return data;
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ type DeleteUserProps = {
};

const DeleteUserModal = ({ onClose }: DeleteUserProps) => {
const { mutate: deleteUserMutate } = useDeleteUser();
const navigate = useNavigate();


const { mutateAsync: deleteUserMutate } = useDeleteUser();
const handleLogout = async () => {
try {
await deleteUserMutate();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { QUERY_KEY } from "../../constants/key";
import { deleteLp } from "../../apis/lp";

type Variables = {
lpId: string;
};
import type { RequestLpDto } from "../../types/lp";

export default function useDeleteLp() {
const qc = useQueryClient();

return useMutation({
mutationFn: ({ lpId }: Variables) => deleteLp(lpId),
mutationFn: ({ lpId }: RequestLpDto) => deleteLp({lpId}),
onSuccess: () => {
// LP 목록 쿼리를 무효화하여 자동 새로고침
qc.invalidateQueries({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default function usePatchLp() {
const qc = useQueryClient();

return useMutation({
mutationFn: ({ lpId, body }: Variables) => patchLp(lpId, body),
mutationFn: ({ lpId, body }: Variables) => patchLp({ lpId, body }),
onSuccess: () => {
// LP 목록 쿼리를 무효화하여 자동 새로고침
qc.invalidateQueries({
Expand Down
2 changes: 1 addition & 1 deletion week07/mission/Chap7_mission/src/pages/LpDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const LpDetailPage = () => {
const handleDeleteLp = () => {
if (!lpid) return;
if (confirm("정말 이 LP를 삭제하시겠습니까?")) {
deleteMutate({ lpId: lpid }, { onSuccess: () => navigate("/") });
deleteMutate({lpId: Number(lpid)}, { onSuccess: () => navigate("/") });
}
};

Expand Down
1 change: 1 addition & 0 deletions week08/mission/Chap8_mission/src/App.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

63 changes: 63 additions & 0 deletions week08/mission/Chap8_mission/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import "./App.css";
import {
createBrowserRouter,
RouterProvider,
type RouteObject,
} from "react-router-dom";

import HomePage from "./pages/HomePage";
import NotFound from "./pages/not-found";
import HomeLayout from "./layout/HomeLayout";
import LoginPage from "./pages/LoginPage";
import SignupPage from "./pages/SignupPage";
import MyPage from "./pages/MyPage";
import { AuthProvider } from "./context/AuthProvider";
import ProtectedLayout from "./layout/ProtectedLayout";
import GoogleLoginRedirectPage from "./pages/GoogleLoginRedirectPage";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { LpDetailPage } from "./pages/LpDetailPage";

// publicRoutes: 인증없이 접근 가능한 라우트
const publicRoutes: RouteObject[] = [
{
path: "/",
element: <HomeLayout />,
errorElement: <NotFound />,
children: [
{ index: true, element: <HomePage /> },
{ path: "login", element: <LoginPage /> },
{ path: "signup", element: <SignupPage /> },
{ path: "v1/auth/google/callback", element: <GoogleLoginRedirectPage /> },
],
},
];

// privateRoutes: 인증 후에 접근 가능한 라우트
const privateRoutes: RouteObject[] = [
{
path: "/",
element: <ProtectedLayout />,
errorElement: <NotFound />,
children: [
{ path: "my", element: <MyPage /> },
{ path: "lp/:lpid", element: <LpDetailPage /> },
],
},
];

const router = createBrowserRouter([...publicRoutes, ...privateRoutes]);
const queryClient = new QueryClient();

function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
);
}

export default App;
104 changes: 104 additions & 0 deletions week08/mission/Chap8_mission/src/apis/axios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import axios, { type InternalAxiosRequestConfig } from "axios";
import { LOCAL_STORAGE_KEY } from "../constants/key";
import { useLocalStorage } from "../hooks/useLocalStorage";

interface CustomInternalAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean; // 요청 재시도 여부를 나타내는 플래그

}

// 전역 변수로 refresh 요청의 Promise를 저장해서 중복 요청을 방지
let refreshPromise:Promise<string | null> | null = null;

export const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_SERVER_API_URL,
});

// 요청 인터셉터 설정: 모든 요청 전에 accessToken을 헤더에 추가
axiosInstance.interceptors.request.use((config) => {
const {getItem} = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
const token = getItem(); // 로컬 스토리지에서 액세스 토큰 가져오기

// accessToken이 존재하면 authorization 헤더에 bearer 토큰으로 추가
if(token){
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token}`; // 헤더에 토큰 추가
}

// 수정된 config 객체 반환
return config;
},
// 요청 에러 처리
(error) => {
return Promise.reject(error);
}
);

// 응답 인터셉터: 401 에러 발생 시 토큰 갱신 시도
axiosInstance.interceptors.response.use(
// 정상 응답 처리
(response) => {
return response;
},
// 에러 응답 처리
async (error) => {
const originalRequest: CustomInternalAxiosRequestConfig = error.config as CustomInternalAxiosRequestConfig;

// 401 에러이면서 재시도 플래그가 설정되지 않은 경우에만 토큰 갱신 시도
if (
error.response &&
error.response.status === 401 &&
!originalRequest._retry) {
// refresh 엔드포인트 401 에러가 발생한 경우 (unauthorized), 중복 재시도 방지를 위해 로그아웃 처리
if(originalRequest.url === '/v1/auth/refresh'){
const {removeItem: removeAccessToken} = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
const {removeItem: removeRefreshToken} = useLocalStorage(LOCAL_STORAGE_KEY.refreshToken);
removeAccessToken();
removeRefreshToken();
window.location.href = '/login';
return Promise.reject(error);
}

// 재시도 플래그 설정
originalRequest._retry = true;

// 이미 refresh 요청이 진행 중인 경우, 기존 Promise를 반환하여 중복 요청 방지
if (!refreshPromise) {
// refresh 요청 실행후, 프라미스를 전역 변수에 할당
refreshPromise = (async () => {
const {getItem: getRefreshToken} = useLocalStorage(LOCAL_STORAGE_KEY.refreshToken);
const refreshToken = getRefreshToken();

const {data} = await axiosInstance.post('/v1/auth/refresh', {
refresh: refreshToken,
});
// 갱신된 토큰을 로컬 스토리지에 저장
const {setItem: setAccessToken} = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
const {setItem: setRefreshToken} = useLocalStorage(LOCAL_STORAGE_KEY.refreshToken);
setAccessToken(data.data.accessToken);
setRefreshToken(data.data.refreshToken);

// 새로운 accessToken 반환
return data.data.accessToken;
})()
.catch( () => {
const {removeItem: removeAccessToken} = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
const {removeItem: removeRefreshToken} = useLocalStorage(LOCAL_STORAGE_KEY.refreshToken);
removeAccessToken();
removeRefreshToken();
}).finally(() => {
refreshPromise = null; // 요청 완료 후 프라미스 초기화
});
}

// 진행 중인 refreshPromise가 해결될 때까지 기다림
return refreshPromise.then((newAccessToken) => {
// 원본 요청의 Authorization 헤더를 갱신된 토큰으로 업데이트
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;

return axiosInstance.request(originalRequest);
});
}
return Promise.reject(error);
},
);
65 changes: 65 additions & 0 deletions week08/mission/Chap8_mission/src/apis/lp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { PaginationDto, CommentPaginationDto } from "../types/common";
import { axiosInstance } from "./axios";
import type { ResponseLikeLpDto, ResponseLpCommentListDto, ResponseLpDetailDto, ResponseLpListDto,
RequestCreateLpDto, ResponseCreateLpDto, LpComment, ResponsePatchLpCommentDto, ResponseDeleteLpCommentDto,
RequestPatchLpDto, ResponseDeleteLpDto, RequestLpDto
} from "../types/lp";

export const getLpList = async (paginationDto: PaginationDto):Promise<ResponseLpListDto> => {
const { data } = await axiosInstance.get("/v1/lps", {
params: paginationDto,
});
return data;
}

export const getLpDetail = async ({lpId}: RequestLpDto):Promise<ResponseLpDetailDto> => {
const { data } = await axiosInstance.get(`/v1/lps/${lpId}`);
return data;
}

export const getLpComments = async (commentPaginationDto: CommentPaginationDto):Promise<ResponseLpCommentListDto> => {
const { data } = await axiosInstance.get(`/v1/lps/${commentPaginationDto.lpId}/comments`,{
params: commentPaginationDto,
});
return data;
}

export const postLike = async ({lpId}: RequestLpDto):Promise<ResponseLikeLpDto> => {
const { data } = await axiosInstance.post(`/v1/lps/${lpId}/likes`);
return data;
}

export const deleteLike = async ({lpId}: RequestLpDto):Promise<ResponseLikeLpDto> => {
const { data } = await axiosInstance.delete(`/v1/lps/${lpId}/likes`);
return data;
}

export const postCreateLp = async (newLp: RequestCreateLpDto):Promise<ResponseCreateLpDto> => {
const { data } = await axiosInstance.post("/v1/lps/", newLp);
return data;
};

export const postComment = async (lpId: string | undefined, body: { content: string }):Promise<LpComment> => {
const { data } = await axiosInstance.post(`/v1/lps/${lpId}/comments`, body);
return data;
};

export const patchComment = async (lpId: string | undefined, commentId:number, body: { content: string }):Promise<ResponsePatchLpCommentDto> => {
const { data } = await axiosInstance.patch(`/v1/lps/${lpId}/comments/${commentId}`, body);
return data;
};

export const deleteComment = async (lpId: string | undefined, commentId:number):Promise<ResponseDeleteLpCommentDto> => {
const { data } = await axiosInstance.delete(`/v1/lps/${lpId}/comments/${commentId}`);
return data;
};

export const patchLp = async (lpId: string | undefined, body: RequestPatchLpDto ):Promise<ResponseLpDetailDto> => {
const { data } = await axiosInstance.patch(`/v1/lps/${lpId}`, body);
return data;
};

export const deleteLp = async (lpId: string | undefined ):Promise<ResponseDeleteLpDto> => {
const { data } = await axiosInstance.delete(`/v1/lps/${lpId}`);
return data;
};
37 changes: 37 additions & 0 deletions week08/mission/Chap8_mission/src/components/AddBtn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useState } from "react";
import AddLpModal from "./AddLpModal";

const AddBtn = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const handleAddModal = () => setIsModalOpen(true);
const handleCloseModal = () => setIsModalOpen(false);

return (
<>
{/* + 버튼 */}
<button
onClick={handleAddModal}
className="group fixed bottom-6 right-6 z-50 flex items-center justify-center
w-14 h-14 bg-white rounded-full shadow-lg
hover:bg-blue-700 transform transition-all duration-200
hover:scale-110"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-7 w-7 text-blue-700 transition-colors duration-200 group-hover:text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
</button>

{/* 모달 */}
{isModalOpen && <AddLpModal onClose={handleCloseModal} />}
</>
);
};

export default AddBtn;
Loading